Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

1,486 linesCodeBlameRaw
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
18use serde::{Deserialize, Serialize};
19
20use super::{CheckStatus, Issue, Mergeable, NewSessionEntry, Overlap, PullStatus, QueueState, RequiredCheck, SessionEntry, State};
21use crate::credentials::Principal;
22use crate::events::Event;
23use crate::repos::RepoPath;
24use crate::{User, Viewer};
25
26/// A session with no heartbeat for this long reads as idle.
27pub const SESSION_IDLE_MS: u64 = 15 * 60 * 1000;
28/// A session with no heartbeat for this long has ended (`timed_out`); a
29/// heartbeat brings it back, so this only says when it stops showing as live.
30pub const SESSION_TIMEOUT_MS: u64 = 30 * 60 * 1000;
31/// How long a claim lasts unless renewed, in minutes.
32pub const CLAIM_DEFAULT_MINUTES: u32 = 30;
33/// The longest a claim may be asked to last, in minutes.
34pub const CLAIM_MAX_MINUTES: u32 = 240;
35/// The most issues one issue can be blocked by.
36pub const MAX_BLOCKED_BY: usize = 50;
37/// The most characters a session's title, a claim's note or a reason keeps.
38pub const MAX_NOTE_CHARS: usize = 500;
39/// The most characters an entry's idempotency key may have.
40pub const MAX_KEY_CHARS: usize = 200;
41
42/// The event types about coordinating work, which `get_work` lists as an
43/// issue's recent activity (with the merge queue's, once it sends them).
44pub const COORDINATION_EVENTS: [&str; 11] = [
45 "session.started",
46 "session.ended",
47 "work.claimed",
48 "work.released",
49 "work.claim_expired",
50 "work.handoff_requested",
51 "dependency.changed",
52 "overlap.detected",
53 "integration.failed",
54 "integration.passed",
55 "decision.recorded",
56];
57
58/// The harness a session runs in.
59#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum Harness {
62 ClaudeCode,
63 /// A session g1t started on Claude Managed Agents with the workspace's
64 /// own Anthropic key (`start_agent_session`).
65 ClaudeManagedAgents,
66 Codex,
67 Cursor,
68 Copilot,
69 Other,
70}
71
72impl Harness {
73 pub const ALL: [Harness; 6] = [
74 Harness::ClaudeCode,
75 Harness::ClaudeManagedAgents,
76 Harness::Codex,
77 Harness::Cursor,
78 Harness::Copilot,
79 Harness::Other,
80 ];
81
82 pub fn as_str(self) -> &'static str {
83 match self {
84 Harness::ClaudeCode => "claude_code",
85 Harness::ClaudeManagedAgents => "claude_managed_agents",
86 Harness::Codex => "codex",
87 Harness::Cursor => "cursor",
88 Harness::Copilot => "copilot",
89 Harness::Other => "other",
90 }
91 }
92
93 /// From its name, in either case, with `-` for `_`.
94 pub fn parse(name: &str) -> Option<Harness> {
95 let name = name.trim().to_ascii_lowercase().replace('-', "_");
96 Harness::ALL.into_iter().find(|harness| harness.as_str() == name)
97 }
98}
99
100/// Where a session stands.
101#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "snake_case")]
103pub enum SessionState {
104 /// Heard from in the last 15 minutes.
105 Active,
106 /// Not heard from for 15 minutes; it may still come back.
107 Idle,
108 /// Ended, or not heard from for 30 minutes (`end_reason` `timed_out`).
109 Ended,
110}
111
112impl SessionState {
113 pub fn as_str(self) -> &'static str {
114 match self {
115 SessionState::Active => "active",
116 SessionState::Idle => "idle",
117 SessionState::Ended => "ended",
118 }
119 }
120
121 pub fn parse(name: &str) -> Option<SessionState> {
122 match name.trim().to_ascii_lowercase().as_str() {
123 "active" => Some(SessionState::Active),
124 "idle" => Some(SessionState::Idle),
125 "ended" => Some(SessionState::Ended),
126 _ => None,
127 }
128 }
129
130 /// A session as it reads at `now`: `ended` when it was ended or has
131 /// been quiet for [`SESSION_TIMEOUT_MS`], `idle` after
132 /// [`SESSION_IDLE_MS`], else `active`.
133 pub fn at(ended: bool, last_seen_ms: u64, now_ms: u64) -> SessionState {
134 let quiet = now_ms.saturating_sub(last_seen_ms);
135 if ended || quiet >= SESSION_TIMEOUT_MS {
136 SessionState::Ended
137 } else if quiet >= SESSION_IDLE_MS {
138 SessionState::Idle
139 } else {
140 SessionState::Active
141 }
142 }
143}
144
145/// One run of someone's own harness on a repository.
146#[derive(Clone, Debug, Serialize, Deserialize)]
147#[serde(rename_all = "camelCase")]
148pub struct Session {
149 /// `ses_…`.
150 pub id: String,
151 pub repo_id: String,
152 /// The issue it works on, if it said.
153 pub issue: Option<u32>,
154 /// The pull request it works on, if it said.
155 pub pull: Option<u32>,
156 pub branch: Option<String>,
157 /// Whose token started it.
158 pub actor: Principal,
159 pub harness: Harness,
160 /// The harness's own id for the session, which makes starting it again
161 /// return this one.
162 pub harness_session_id: Option<String>,
163 pub title: Option<String>,
164 pub state: SessionState,
165 /// The commit it last said it was at.
166 pub head_commit: Option<String>,
167 /// RFC 3339.
168 pub started_at: String,
169 /// RFC 3339: its last heartbeat or change.
170 pub last_seen_at: String,
171 /// RFC 3339, once ended.
172 pub ended_at: Option<String>,
173 /// Why it ended: what the harness said, or `timed_out`.
174 pub end_reason: Option<String>,
175 /// For a subagent: the session that started it (`ses_…`).
176 #[serde(default)]
177 pub parent_id: Option<String>,
178 /// For a subagent: its kind, as the harness names it (`Explore`,
179 /// `security-reviewer`, a Managed Agents thread's agent).
180 #[serde(default)]
181 pub agent_type: Option<String>,
182 /// For a subagent: the task it was given, in a few words.
183 #[serde(default)]
184 pub task: Option<String>,
185 /// For a subagent: the prompt its parent gave it, cut to
186 /// [`MAX_PROMPT_CHARS`].
187 #[serde(default)]
188 pub prompt: Option<String>,
189 /// What it reported when it finished (a subagent's final message), cut to
190 /// [`MAX_RESULT_CHARS`]: its parent's evidence, never a tool's output.
191 #[serde(default)]
192 pub result: Option<String>,
193 /// The model it ran on, when the harness said.
194 #[serde(default)]
195 pub model: Option<String>,
196 /// How many subagents it started.
197 #[serde(default)]
198 pub subagents: u32,
199 /// For a session with no title, in a list: the start of the first
200 /// prompt it recorded (up to [`MAX_FIRST_PROMPT_CHARS`]), for saying
201 /// what it is for until it has a title.
202 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub first_prompt: Option<String>,
204}
205
206/// The most characters of an untitled session's first prompt a list gives.
207pub const MAX_FIRST_PROMPT_CHARS: usize = 300;
208
209/// The most characters a subagent's task keeps.
210pub const MAX_TASK_CHARS: usize = 200;
211/// The most characters of a subagent's prompt kept.
212pub const MAX_PROMPT_CHARS: usize = 4_000;
213/// The most characters of a session's result kept.
214pub const MAX_RESULT_CHARS: usize = 8_000;
215/// The most things one call says a session touched.
216pub const MAX_TOUCHES: usize = 200;
217/// The most characters a touched path, branch or commit keeps.
218pub const MAX_TOUCH_CHARS: usize = 500;
219
220/// The harness's id for a subagent of the harness session `parent`: what
221/// its start is idempotent on. Claude Code's `agent_id` arrives as
222/// `agent-abc` in hooks and `abc` in the Agent tool's answer, so the prefix
223/// is dropped.
224pub fn subagent_harness_id(parent: &str, agent_id: &str) -> String {
225 let agent = agent_id.trim();
226 let agent = agent.strip_prefix("agent-").unwrap_or(agent);
227 format!("{}/agent/{agent}", parent.trim())
228}
229
230/// What kind of thing a session touched.
231#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
232#[serde(rename_all = "snake_case")]
233pub enum TouchKind {
234 /// A file it wrote or edited, relative to the repository's root.
235 File,
236 /// A commit it made.
237 Commit,
238 /// A branch it made or pushed.
239 Branch,
240 /// A pull request it opened.
241 Pull,
242 /// An issue it named.
243 Issue,
244}
245
246impl TouchKind {
247 pub fn as_str(self) -> &'static str {
248 match self {
249 TouchKind::File => "file",
250 TouchKind::Commit => "commit",
251 TouchKind::Branch => "branch",
252 TouchKind::Pull => "pull",
253 TouchKind::Issue => "issue",
254 }
255 }
256
257 pub fn parse(name: &str) -> Option<TouchKind> {
258 [TouchKind::File, TouchKind::Commit, TouchKind::Branch, TouchKind::Pull, TouchKind::Issue]
259 .into_iter()
260 .find(|kind| kind.as_str() == name.trim())
261 }
262}
263
264/// Something a session says it touched, to record.
265#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
266pub struct NewTouch {
267 pub kind: TouchKind,
268 /// The path, commit, branch, or number (`12`) for a pull request or issue.
269 pub value: String,
270}
271
272/// Something a session touched: recorded once per session, kind and value.
273#[derive(Clone, Debug, Serialize, Deserialize)]
274#[serde(rename_all = "camelCase")]
275pub struct SessionTouch {
276 pub session_id: String,
277 pub kind: TouchKind,
278 pub value: String,
279 /// RFC 3339: when it was first said.
280 pub at: String,
281}
282
283/// Touches as given, tidied: trimmed, cut, numbers for pull requests and
284/// issues, empty and repeated ones dropped, at most [`MAX_TOUCHES`].
285pub fn tidy_touches(touches: &[NewTouch]) -> Vec<NewTouch> {
286 let mut out: Vec<NewTouch> = Vec::new();
287 for touch in touches {
288 let mut value: String = touch.value.trim().chars().take(MAX_TOUCH_CHARS).collect();
289 match touch.kind {
290 TouchKind::Pull | TouchKind::Issue => {
291 match value.trim_start_matches('#').parse::<u32>() {
292 Ok(number) if number > 0 => value = number.to_string(),
293 _ => continue,
294 }
295 }
296 TouchKind::Commit => {
297 if value.len() < 7 || !value.chars().all(|c| c.is_ascii_hexdigit()) {
298 continue;
299 }
300 value = value.to_ascii_lowercase();
301 }
302 TouchKind::Branch => value = value.trim_start_matches("refs/heads/").to_owned(),
303 TouchKind::File => value = value.replace('\\', "/"),
304 }
305 if value.is_empty() || out.iter().any(|seen| seen.kind == touch.kind && seen.value == value) {
306 continue;
307 }
308 out.push(NewTouch { kind: touch.kind, value });
309 if out.len() == MAX_TOUCHES {
310 break;
311 }
312 }
313 out
314}
315
316/// The issue and pull request numbers `#N` in text names, in order, once
317/// each: `#12`, `(#12)`, `fixes #12`, never `a#12`, `&#12;` or `#12abc`.
318pub fn refs_in(text: &str) -> Vec<u32> {
319 let bytes = text.as_bytes();
320 let mut found = Vec::new();
321 let mut at = 0;
322 while let Some(offset) = text[at..].find('#') {
323 let hash = at + offset;
324 at = hash + 1;
325 let before = hash.checked_sub(1).map(|i| bytes[i]);
326 if before.is_some_and(|b| b.is_ascii_alphanumeric() || b == b'&' || b == b'/' || b == b'_') {
327 continue;
328 }
329 let digits: String = text[at..].chars().take_while(char::is_ascii_digit).collect();
330 if digits.is_empty() || digits.len() > 7 {
331 continue;
332 }
333 let after = text[at + digits.len()..].chars().next();
334 if after.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == ';') {
335 continue;
336 }
337 if let Ok(number) = digits.parse::<u32>()
338 && number > 0
339 && !found.contains(&number)
340 {
341 found.push(number);
342 }
343 }
344 found
345}
346
347/// The sessions a commit message names in `G1t-Session: ses_…` trailers.
348pub fn session_trailers(message: &str) -> Vec<String> {
349 let mut found = Vec::new();
350 for line in message.lines() {
351 let Some((key, value)) = line.split_once(':') else {
352 continue;
353 };
354 if !key.trim().eq_ignore_ascii_case("g1t-session") {
355 continue;
356 }
357 let id = value.trim();
358 if id.starts_with("ses_") && id.len() <= 64 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && !found.iter().any(|f| f == id) {
359 found.push(id.to_owned());
360 }
361 }
362 found
363}
364
365/// How a session was tied to an issue or pull request.
366#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
367#[serde(rename_all = "snake_case")]
368pub enum LinkVia {
369 /// The session said it works on it (start or update).
370 Attached,
371 /// It claimed the issue.
372 Claim,
373 /// It pushed the pull request's branch.
374 Branch,
375 /// It made the pull request's head commit.
376 Commit,
377 /// A commit on the pull request's branch names it in a `G1t-Session:`
378 /// trailer.
379 Trailer,
380 /// It opened the pull request.
381 Opened,
382 /// Its prompt, task or a subagent's named it (`#12`).
383 Ref,
384}
385
386impl LinkVia {
387 pub fn as_str(self) -> &'static str {
388 match self {
389 LinkVia::Attached => "attached",
390 LinkVia::Claim => "claim",
391 LinkVia::Branch => "branch",
392 LinkVia::Commit => "commit",
393 LinkVia::Trailer => "trailer",
394 LinkVia::Opened => "opened",
395 LinkVia::Ref => "ref",
396 }
397 }
398
399 pub fn parse(name: &str) -> Option<LinkVia> {
400 [LinkVia::Attached, LinkVia::Claim, LinkVia::Branch, LinkVia::Commit, LinkVia::Trailer, LinkVia::Opened, LinkVia::Ref]
401 .into_iter()
402 .find(|via| via.as_str() == name)
403 }
404}
405
406/// Issue or pull request.
407#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
408#[serde(rename_all = "snake_case")]
409pub enum LinkKind {
410 Issue,
411 Pull,
412}
413
414/// An issue or pull request a session, or one of its subagents, worked on.
415#[derive(Clone, Debug, Serialize, Deserialize)]
416#[serde(rename_all = "camelCase")]
417pub struct SessionLink {
418 pub kind: LinkKind,
419 pub number: u32,
420 pub title: String,
421 /// The issue's state (`open`, `closed`) or the pull request's status.
422 pub state: String,
423 /// How it was tied, strongest first.
424 pub via: Vec<LinkVia>,
425 /// The sessions of the tree it was tied through.
426 pub session_ids: Vec<String>,
427}
428
429/// One tie of a session to an issue or pull request, as stored.
430#[derive(Clone, Debug, PartialEq, Eq)]
431pub struct LinkRecord {
432 pub session_id: String,
433 pub kind: LinkKind,
434 pub number: u32,
435 pub via: LinkVia,
436}
437
438/// The links a session tree's ties make: one per issue or pull request,
439/// its ways gathered strongest first, the pull requests first, then the
440/// issues, each by number. `known` gives each one's title and state; a tie
441/// to anything it does not know (deleted, or a `#N` that names nothing) is
442/// left out.
443pub fn gather_links(records: &[LinkRecord], known: &std::collections::HashMap<(LinkKind, u32), (String, String)>) -> Vec<SessionLink> {
444 let mut links: Vec<SessionLink> = Vec::new();
445 for record in records {
446 let Some((title, state)) = known.get(&(record.kind, record.number)) else {
447 continue;
448 };
449 let link = match links.iter_mut().find(|link| link.kind == record.kind && link.number == record.number) {
450 Some(link) => link,
451 None => {
452 links.push(SessionLink {
453 kind: record.kind,
454 number: record.number,
455 title: title.clone(),
456 state: state.clone(),
457 via: Vec::new(),
458 session_ids: Vec::new(),
459 });
460 links.last_mut().expect("just pushed")
461 }
462 };
463 if !link.via.contains(&record.via) {
464 link.via.push(record.via);
465 link.via.sort();
466 }
467 if !link.session_ids.contains(&record.session_id) {
468 link.session_ids.push(record.session_id.clone());
469 }
470 }
471 links.sort_by_key(|link| (std::cmp::Reverse(link.kind), link.number));
472 links
473}
474
475/// A skill a session used (Claude Code's Skill tool): its name only.
476#[derive(Clone, Debug, Serialize, Deserialize)]
477#[serde(rename_all = "camelCase")]
478pub struct SessionSkill {
479 pub session_id: String,
480 pub name: String,
481 /// How many times it was used.
482 pub uses: u32,
483}
484
485/// Where a claim stands.
486#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
487#[serde(rename_all = "snake_case")]
488pub enum ClaimState {
489 Active,
490 Released,
491 Expired,
492 HandedOff,
493}
494
495impl ClaimState {
496 pub fn as_str(self) -> &'static str {
497 match self {
498 ClaimState::Active => "active",
499 ClaimState::Released => "released",
500 ClaimState::Expired => "expired",
501 ClaimState::HandedOff => "handed_off",
502 }
503 }
504
505 pub fn parse(name: &str) -> Option<ClaimState> {
506 match name.trim().to_ascii_lowercase().as_str() {
507 "active" => Some(ClaimState::Active),
508 "released" => Some(ClaimState::Released),
509 "expired" => Some(ClaimState::Expired),
510 "handed_off" => Some(ClaimState::HandedOff),
511 _ => None,
512 }
513 }
514}
515
516/// An advisory, expiring "I'm working on #N". Never a lock.
517#[derive(Clone, Debug, Serialize, Deserialize)]
518#[serde(rename_all = "camelCase")]
519pub struct Claim {
520 /// `clm_…`.
521 pub id: String,
522 pub repo_id: String,
523 /// The issue it is on.
524 pub issue: u32,
525 /// The session it was made from, whose heartbeats renew it.
526 pub session_id: Option<String>,
527 pub actor: Principal,
528 pub harness: Option<Harness>,
529 pub note: Option<String>,
530 pub state: ClaimState,
531 /// RFC 3339.
532 pub created_at: String,
533 /// RFC 3339: when it lapses unless renewed.
534 pub expires_at: String,
535 /// RFC 3339, once it stopped being active.
536 pub ended_at: Option<String>,
537 pub end_reason: Option<String>,
538 /// For a hand-off: the username it was handed to; none for anyone.
539 pub handoff_to: Option<String>,
540}
541
542/// Whether a claim (made by `claim_actor` from `claim_session`) is the
543/// caller's own (`actor`, acting from `session`): theirs, and from the same
544/// session when both name one. Two sessions of one person, such as sibling
545/// subagents, hold claims of their own and are told of each other's.
546pub fn claim_is_mine(claim_actor: &str, claim_session: Option<&str>, actor: &str, session: Option<&str>) -> bool {
547 claim_actor == actor
548 && match (claim_session, session) {
549 (Some(held), Some(asking)) => held == asking,
550 _ => true,
551 }
552}
553
554/// How long a claim lasts, in minutes: the default when none was asked,
555/// at least one and at most [`CLAIM_MAX_MINUTES`].
556pub fn claim_minutes(asked: Option<u32>) -> u32 {
557 asked.unwrap_or(CLAIM_DEFAULT_MINUTES).clamp(1, CLAIM_MAX_MINUTES)
558}
559
560/// `blocked_by` as asked for `number`, de-duplicated in the order given,
561/// or why it cannot be: one names `number` itself or is not an issue of
562/// the repository (`issues`), there are too many, or it would close a
563/// cycle through the blockers already recorded (`graph`: each issue's
564/// `blocked_by`).
565pub fn check_blocked_by(
566 number: u32,
567 asked: &[u32],
568 issues: &std::collections::HashSet<u32>,
569 graph: &std::collections::HashMap<u32, Vec<u32>>,
570) -> Result<Vec<u32>, String> {
571 let mut wanted: Vec<u32> = Vec::new();
572 for blocker in asked {
573 if !wanted.contains(blocker) {
574 wanted.push(*blocker);
575 }
576 }
577 if wanted.len() > MAX_BLOCKED_BY {
578 return Err(format!("An issue can be blocked by at most {MAX_BLOCKED_BY} issues."));
579 }
580 if wanted.contains(&number) {
581 return Err(format!("#{number} cannot be blocked by itself."));
582 }
583 if let Some(missing) = wanted.iter().find(|blocker| !issues.contains(blocker)) {
584 return Err(format!("#{missing} is not an issue in this repository."));
585 }
586 // A cycle: some blocker is itself, through what blocks it, blocked by
587 // `number`.
588 let mut seen = std::collections::HashSet::new();
589 let mut stack: Vec<(u32, Vec<u32>)> = wanted.iter().map(|blocker| (*blocker, vec![number, *blocker])).collect();
590 while let Some((at, path)) = stack.pop() {
591 if !seen.insert(at) {
592 continue;
593 }
594 for next in graph.get(&at).into_iter().flatten() {
595 let mut longer = path.clone();
596 longer.push(*next);
597 if *next == number {
598 let chain = longer.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(" → ");
599 return Err(format!("That would make a cycle: {chain}, where each is blocked by the next."));
600 }
601 stack.push((*next, longer));
602 }
603 }
604 Ok(wanted)
605}
606
607/// The items of an issue's "Definition of done" section: each bulleted or
608/// numbered line under the heading, up to the next heading.
609pub fn definition_of_done(body: &str) -> Vec<String> {
610 let mut items = Vec::new();
611 let mut inside = false;
612 for line in body.lines() {
613 let trimmed = line.trim();
614 if trimmed.starts_with('#') {
615 inside = trimmed.trim_start_matches('#').trim().eq_ignore_ascii_case("definition of done");
616 continue;
617 }
618 if !inside {
619 continue;
620 }
621 let item = trimmed
622 .strip_prefix("- ")
623 .or_else(|| trimmed.strip_prefix("* "))
624 .or_else(|| trimmed.split_once(". ").filter(|(n, _)| n.chars().all(|c| c.is_ascii_digit()) && !n.is_empty()).map(|(_, rest)| rest));
625 if let Some(item) = item {
626 let item = item.trim_start_matches("[ ] ").trim_start_matches("[x] ").trim_start_matches("[X] ").trim();
627 if !item.is_empty() {
628 items.push(item.to_owned());
629 }
630 }
631 }
632 items
633}
634
635/// The pairs of pull requests that overlap now, against those already
636/// announced: which are new (to announce) and which cleared (to forget, so
637/// that they are announced again if they come back).
638pub fn overlap_changes(current: &[u32], announced: &[u32]) -> (Vec<u32>, Vec<u32>) {
639 let new = current.iter().filter(|number| !announced.contains(number)).copied().collect();
640 let cleared = announced.iter().filter(|number| !current.contains(number)).copied().collect();
641 (new, cleared)
642}
643
644// --- Arguments --------------------------------------------------------------
645
646/// `start_session`. Returns `Outcome<Session>`: a new one, or the one
647/// already started with the same `harness_session_id` (which then takes
648/// the issue, pull request, branch, title and head given).
649#[derive(Debug, Serialize, Deserialize)]
650pub struct StartSessionArgs {
651 pub actor: User,
652 pub repo: RepoPath,
653 pub harness: Harness,
654 #[serde(default)]
655 pub harness_session_id: Option<String>,
656 #[serde(default)]
657 pub issue: Option<u32>,
658 #[serde(default)]
659 pub pull: Option<u32>,
660 #[serde(default)]
661 pub branch: Option<String>,
662 #[serde(default)]
663 pub title: Option<String>,
664 #[serde(default)]
665 pub head_commit: Option<String>,
666 /// For a subagent: the session that started it, one of the actor's on
667 /// the repository. Its `harness_session_id` should be
668 /// [`subagent_harness_id`] of the parent's.
669 #[serde(default)]
670 pub parent_id: Option<String>,
671 #[serde(default)]
672 pub agent_type: Option<String>,
673 #[serde(default)]
674 pub task: Option<String>,
675 #[serde(default)]
676 pub prompt: Option<String>,
677 #[serde(default)]
678 pub model: Option<String>,
679}
680
681/// `update_session`: changes whichever fields are given; with none, it is a
682/// heartbeat. Either way it renews the session's active claims. Returns
683/// `Outcome<Session>`.
684#[derive(Debug, Serialize, Deserialize)]
685pub struct UpdateSessionArgs {
686 pub actor: User,
687 pub repo: RepoPath,
688 pub id: String,
689 #[serde(default)]
690 pub issue: Option<u32>,
691 #[serde(default)]
692 pub pull: Option<u32>,
693 #[serde(default)]
694 pub branch: Option<String>,
695 #[serde(default)]
696 pub head_commit: Option<String>,
697 #[serde(default)]
698 pub title: Option<String>,
699 #[serde(default)]
700 pub agent_type: Option<String>,
701 #[serde(default)]
702 pub task: Option<String>,
703 #[serde(default)]
704 pub prompt: Option<String>,
705 #[serde(default)]
706 pub model: Option<String>,
707 #[serde(default)]
708 pub result: Option<String>,
709}
710
711/// `end_session`. Returns `Outcome<Session>`; ending one already ended
712/// returns it as it is (taking `result` if it had none). Ending a session
713/// ends its subagents still going. Whoever runs a session ends it, and so
714/// may anyone with write access to the repository, for one left behind
715/// (its harness killed, or never told to end): see [`may_end_session`] and
716/// [`session_end_reason`].
717#[derive(Debug, Serialize, Deserialize)]
718pub struct EndSessionArgs {
719 pub actor: User,
720 pub repo: RepoPath,
721 pub id: String,
722 #[serde(default)]
723 pub reason: Option<String>,
724 /// What it reported when it finished.
725 #[serde(default)]
726 pub result: Option<String>,
727 /// Ended by a person from the site, not by its harness: recorded as
728 /// [`ended_by_hand`] whatever `reason` says.
729 #[serde(default)]
730 pub by_hand: bool,
731}
732
733/// How every [`ended_by_hand`] reason starts.
734pub const ENDED_BY_HAND: &str = "ended by hand by @";
735
736/// The `end_reason` of a session a person ended by hand (from the site,
737/// or someone else's): `ended by hand by @name`.
738pub fn ended_by_hand(username: &str) -> String {
739 format!("{ENDED_BY_HAND}{username}")
740}
741
742/// Whether a session that ended for `end_reason` comes back when its
743/// harness is heard from again (an update, a record, a start): one that
744/// timed out, or one a person ended by hand, which is a soft close for a
745/// session left behind. One its harness ended itself stays ended.
746pub fn revives(end_reason: Option<&str>) -> bool {
747 end_reason.is_some_and(|reason| reason == "timed_out" || reason.starts_with(ENDED_BY_HAND))
748}
749
750/// Whether `actor` may end a session `owner` runs: their own, or anyone's
751/// when they have write access (`can_push`) to the repository.
752pub fn may_end_session(owner: &str, actor: &str, can_push: bool) -> bool {
753 owner == actor || can_push
754}
755
756/// Why a session ended, as `end_session` records it: [`ended_by_hand`]
757/// when a person ended it by hand or it was not theirs (so it never reads
758/// as its harness's own words), else what the harness said, or `ended`.
759pub fn session_end_reason(own: bool, by_hand: bool, actor: &str, given: Option<&str>) -> String {
760 if by_hand || !own {
761 return ended_by_hand(actor);
762 }
763 given.map(str::trim).filter(|reason| !reason.is_empty()).map_or_else(|| "ended".to_owned(), str::to_owned)
764}
765
766/// `get_work_session`: a session and its entries after `after_seq`.
767/// Returns `Outcome<SessionDetail>`.
768#[derive(Debug, Serialize, Deserialize)]
769pub struct WorkSessionArgs {
770 pub viewer: Viewer,
771 pub repo: RepoPath,
772 pub id: String,
773 #[serde(default)]
774 pub after_seq: u32,
775}
776
777#[derive(Clone, Debug, Serialize, Deserialize)]
778pub struct SessionUpdate {
779 pub id: String,
780 pub kind: String,
781 pub data: serde_json::Value,
782 pub at: String,
783}
784
785#[derive(Clone, Debug, Serialize, Deserialize)]
786pub struct SessionDetail {
787 /// Oldest pending coordination updates, at most 100. Reading does not acknowledge them.
788 #[serde(default)]
789 pub updates: Vec<SessionUpdate>,
790 pub session: Session,
791 /// Oldest first, numbered within the session.
792 pub entries: Vec<SessionEntry>,
793 /// Its subagents, and theirs, in the order they started: each names its
794 /// parent in `parent_id`.
795 #[serde(default)]
796 pub children: Vec<Session>,
797 /// What it and its subagents touched, in the order first said.
798 #[serde(default)]
799 pub touched: Vec<SessionTouch>,
800 /// The issues and pull requests it and its subagents worked on.
801 #[serde(default)]
802 pub links: Vec<SessionLink>,
803 /// The skills it and its subagents used, by name.
804 #[serde(default)]
805 pub skills: Vec<SessionSkill>,
806 /// For a subagent: the session at the top of its tree.
807 #[serde(default)]
808 pub root: Option<Session>,
809 /// The viewer is not a member of the repository's workspace: they see
810 /// that the session runs, who runs it and what it is tied to, and
811 /// nothing of what it recorded, touched or was asked.
812 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
813 pub limited: bool,
814}
815
816/// `pull_sessions`: the harness sessions tied to some of a repository's
817/// pull requests (`numbers`, at most 100) any way a session is tied to
818/// one, for Home's marks on what landed. Returns
819/// `Outcome<Vec<PullSessions>>`, one entry for each pull request that has
820/// any, its top-level sessions first: a subagent counts as the session
821/// that started it.
822#[derive(Debug, Serialize, Deserialize)]
823pub struct PullSessionsArgs {
824 pub viewer: Viewer,
825 pub repo: RepoPath,
826 pub numbers: Vec<u32>,
827}
828
829/// The sessions tied to one pull request.
830#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
831#[serde(rename_all = "camelCase")]
832pub struct PullSessions {
833 pub number: u32,
834 pub sessions: Vec<SessionRef>,
835}
836
837/// A session, as a mark on something it made: its id and harness.
838#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
839#[serde(rename_all = "camelCase")]
840pub struct SessionRef {
841 pub id: String,
842 pub harness: Harness,
843}
844
845/// `list_work_sessions`: a repository's sessions, most recently heard from
846/// first. Returns `Outcome<Vec<Session>>`. Subagents are left out unless
847/// `subagents`; with `issue` or `pull`, the sessions tied to it any way
848/// (attached, a claim, its branch or commits, a trailer, opened, named)
849/// are listed, and with `subagents` their subagents too.
850#[derive(Debug, Serialize, Deserialize)]
851pub struct ListWorkSessionsArgs {
852 pub viewer: Viewer,
853 pub repo: RepoPath,
854 #[serde(default)]
855 pub state: Option<SessionState>,
856 #[serde(default)]
857 pub issue: Option<u32>,
858 #[serde(default)]
859 pub pull: Option<u32>,
860 #[serde(default)]
861 pub subagents: bool,
862 /// Only sessions that touched this file, or any under it when it ends
863 /// in `/`.
864 #[serde(default)]
865 pub path: Option<String>,
866}
867
868/// An entry to record in a session. `key` makes recording it again a no-op.
869#[derive(Clone, Debug, Serialize, Deserialize)]
870pub struct KeyedSessionEntry {
871 #[serde(flatten)]
872 pub entry: NewSessionEntry,
873 #[serde(default)]
874 pub key: Option<String>,
875}
876
877/// `record_session_entries`. Returns `Outcome<Recorded>`.
878#[derive(Debug, Serialize, Deserialize)]
879pub struct RecordEntriesArgs {
880 /// Exact update IDs this session has considered. Repeated acknowledgements are harmless.
881 #[serde(default)]
882 pub acknowledge: Vec<String>,
883 pub actor: User,
884 pub repo: RepoPath,
885 pub id: String,
886 pub entries: Vec<KeyedSessionEntry>,
887 /// What it touched: files, commits, branches, pull requests opened,
888 /// issues named. Recorded once each; pull requests and issues tie the
889 /// session to them.
890 #[serde(default)]
891 pub touched: Vec<NewTouch>,
892}
893
894#[derive(Clone, Debug, Default, Serialize, Deserialize)]
895pub struct Recorded {
896 /// Entries newly recorded.
897 pub recorded: u32,
898 /// Entries skipped because one with the same key was recorded before.
899 pub duplicates: u32,
900 /// Of the `acknowledge` IDs given, how many name an update of this
901 /// session (acknowledged now or before). Fewer than given means some
902 /// IDs were mistyped or belong to another session.
903 #[serde(default)]
904 pub acknowledged: u32,
905}
906
907/// `claim_issue`. Returns `Outcome<Claimed>`. Claiming an issue you hold
908/// renews your claim (and changes its note or session when given). With
909/// no number and a `title`, it opens the issue first and attaches the
910/// session to it; `Claimed::issue` is the issue it opened.
911#[derive(Clone, Debug, Serialize, Deserialize)]
912pub struct ClaimArgs {
913 pub actor: User,
914 pub repo: RepoPath,
915 /// The issue to claim; 0 with `title` to open one and claim it.
916 #[serde(default)]
917 pub number: u32,
918 /// Without a number: the title of a new issue to open and claim. The
919 /// session named by `session_id` is attached to it.
920 #[serde(default)]
921 pub title: Option<String>,
922 /// Without a number: the new issue's description.
923 #[serde(default)]
924 pub body: Option<String>,
925 #[serde(default)]
926 pub session_id: Option<String>,
927 #[serde(default)]
928 pub note: Option<String>,
929 #[serde(default)]
930 pub ttl_minutes: Option<u32>,
931}
932
933#[derive(Clone, Debug, Serialize, Deserialize)]
934#[serde(rename_all = "camelCase")]
935pub struct Claimed {
936 pub claim: Claim,
937 /// Other active claims on the issue: someone else is on it too.
938 pub others: Vec<Claim>,
939 /// What the issue is blocked by, and where each stands.
940 pub blocked_by: Vec<Blocker>,
941 /// The issue the claim opened, when it was asked to open one (a
942 /// `title` instead of a number).
943 #[serde(default, skip_serializing_if = "Option::is_none")]
944 pub issue: Option<Issue>,
945}
946
947/// `release_claim`: gives up your active claim on an issue. Returns
948/// `Outcome<Claim>`.
949#[derive(Debug, Serialize, Deserialize)]
950pub struct ReleaseArgs {
951 pub actor: User,
952 pub repo: RepoPath,
953 pub number: u32,
954 #[serde(default)]
955 pub reason: Option<String>,
956 /// The session whose claim it is, when the caller has several.
957 #[serde(default)]
958 pub session_id: Option<String>,
959}
960
961/// `hand_off_issue`: ends your active claim on an issue as handed off, to
962/// someone (`to`, a username) or anyone, with a note on where it stands.
963/// Returns `Outcome<Claim>`.
964#[derive(Debug, Serialize, Deserialize)]
965pub struct HandOffArgs {
966 pub actor: User,
967 pub repo: RepoPath,
968 pub number: u32,
969 #[serde(default)]
970 pub to: Option<String>,
971 pub note: String,
972 /// The session whose claim it is, when the caller has several.
973 #[serde(default)]
974 pub session_id: Option<String>,
975}
976
977/// `list_claims`: a repository's claims, newest first; active ones unless
978/// `state` says otherwise. Returns `Outcome<Vec<Claim>>`.
979#[derive(Debug, Serialize, Deserialize)]
980pub struct ListClaimsArgs {
981 pub viewer: Viewer,
982 pub repo: RepoPath,
983 #[serde(default)]
984 pub state: Option<ClaimState>,
985 #[serde(default)]
986 pub issue: Option<u32>,
987}
988
989/// `get_work`: everything an agent needs to pick up an issue. Returns
990/// `Outcome<WorkContext>`.
991#[derive(Debug, Serialize, Deserialize)]
992pub struct GetWorkArgs {
993 pub viewer: Viewer,
994 pub repo: RepoPath,
995 pub number: u32,
996}
997
998/// An issue in brief.
999#[derive(Clone, Debug, Serialize, Deserialize)]
1000pub struct IssueBrief {
1001 pub number: u32,
1002 pub title: String,
1003 pub state: State,
1004}
1005
1006/// A pull request in brief.
1007#[derive(Clone, Debug, Serialize, Deserialize)]
1008pub struct PullBrief {
1009 pub number: u32,
1010 pub title: String,
1011 pub status: PullStatus,
1012}
1013
1014/// An issue another is blocked by, and where it stands.
1015#[derive(Clone, Debug, Serialize, Deserialize)]
1016pub struct Blocker {
1017 pub number: u32,
1018 pub title: String,
1019 pub state: State,
1020 /// Active claims on it.
1021 pub claims: Vec<Claim>,
1022 /// Its pull requests.
1023 pub pulls: Vec<PullBrief>,
1024}
1025
1026/// A pull request's place in the merge queue.
1027#[derive(Clone, Debug, Serialize, Deserialize)]
1028pub struct QueuePlace {
1029 pub state: QueueState,
1030 /// 1 for the front of the queue.
1031 pub position: u32,
1032}
1033
1034/// One of an issue's pull requests, with where it stands.
1035#[derive(Clone, Debug, Serialize, Deserialize)]
1036#[serde(rename_all = "camelCase")]
1037pub struct WorkPull {
1038 pub number: u32,
1039 pub title: String,
1040 pub status: PullStatus,
1041 pub branch: Option<String>,
1042 pub head_commit: Option<String>,
1043 pub author: Principal,
1044 /// `failed` when the merge queue took it out, until its head moves.
1045 pub check_status: Option<CheckStatus>,
1046 /// The checks the branch it merges into requires, as they stand on its
1047 /// head.
1048 pub required_checks: Vec<RequiredCheck>,
1049 pub mergeable: Mergeable,
1050 /// Other pull requests in progress changing the same files.
1051 pub overlaps: Vec<Overlap>,
1052 /// Its place in the merge queue, while it is in it.
1053 pub queue: Option<QueuePlace>,
1054}
1055
1056/// Everything about an issue an agent needs to pick it up: the one call to
1057/// make before starting.
1058#[derive(Clone, Debug, Serialize, Deserialize)]
1059#[serde(rename_all = "camelCase")]
1060pub struct WorkContext {
1061 pub issue: Issue,
1062 /// The items under its "Definition of done" heading.
1063 pub definition_of_done: Vec<String>,
1064 /// The checks every pull request into the default branch must pass.
1065 pub required_checks: Vec<String>,
1066 /// Active claims on it.
1067 pub claims: Vec<Claim>,
1068 /// Sessions on it or its pull requests, most recently heard from first.
1069 pub sessions: Vec<Session>,
1070 pub pulls: Vec<WorkPull>,
1071 /// What it is blocked by, and where each stands.
1072 pub blocked_by: Vec<Blocker>,
1073 /// The issues it blocks.
1074 pub blocks: Vec<IssueBrief>,
1075 /// Decision records on it and its pull requests, newest first.
1076 #[serde(default)]
1077 pub decisions: Vec<crate::decisions::Decision>,
1078 /// The latest `integration_failure` decision on it or its pull
1079 /// requests, if any: which combination failed, and the check.
1080 #[serde(default)]
1081 pub last_integration_failure: Option<crate::decisions::Decision>,
1082 /// The latest coordination events about it, newest first.
1083 pub recent: Vec<Event>,
1084}
1085
1086// --- Events -----------------------------------------------------------------
1087
1088/// `session.started` and `session.ended`.
1089#[derive(Clone, Debug, Serialize, Deserialize)]
1090#[serde(rename_all = "camelCase")]
1091pub struct SessionEvent {
1092 pub repo_id: String,
1093 /// The pull request it works on, else its issue.
1094 #[serde(default, skip_serializing_if = "Option::is_none")]
1095 pub number: Option<u32>,
1096 #[serde(default, skip_serializing_if = "Option::is_none")]
1097 pub issue: Option<u32>,
1098 pub session: Session,
1099}
1100
1101impl SessionEvent {
1102 pub fn of(session: &Session) -> SessionEvent {
1103 SessionEvent {
1104 repo_id: session.repo_id.clone(),
1105 number: session.pull.or(session.issue),
1106 issue: session.issue,
1107 session: session.clone(),
1108 }
1109 }
1110}
1111
1112/// The most often a session's recording is announced as
1113/// `session.progressed`, so its open pages follow it without hearing of
1114/// every tool call.
1115pub const PROGRESS_EVERY_MS: u64 = 5_000;
1116
1117/// `session.progressed`: a session (or a subagent of it) recorded more, for
1118/// the pages showing it. Says where, never what.
1119#[derive(Clone, Debug, Serialize, Deserialize)]
1120#[serde(rename_all = "camelCase")]
1121pub struct SessionProgressed {
1122 pub repo_id: String,
1123 /// The pull request it works on, else its issue.
1124 #[serde(default, skip_serializing_if = "Option::is_none")]
1125 pub number: Option<u32>,
1126 pub session_id: String,
1127 /// The session at the top of its tree, when it is a subagent.
1128 #[serde(default, skip_serializing_if = "Option::is_none")]
1129 pub root_id: Option<String>,
1130}
1131
1132/// `work.claimed`, `work.released`, `work.claim_expired` and
1133/// `work.handoff_requested`: `number` and `issue` are the issue claimed.
1134#[derive(Clone, Debug, Serialize, Deserialize)]
1135#[serde(rename_all = "camelCase")]
1136pub struct ClaimEvent {
1137 pub repo_id: String,
1138 pub number: u32,
1139 pub issue: u32,
1140 pub claim: Claim,
1141 /// On `work.claimed`: the other active claims on the issue.
1142 #[serde(default, skip_serializing_if = "Option::is_none")]
1143 pub others: Option<Vec<Claim>>,
1144 /// On `work.claimed`, when it takes over a claim handed off.
1145 #[serde(default, skip_serializing_if = "Option::is_none")]
1146 pub took_over: Option<String>,
1147 /// On `work.released`.
1148 #[serde(default, skip_serializing_if = "Option::is_none")]
1149 pub reason: Option<String>,
1150 /// On `work.handoff_requested`: who handed it off.
1151 #[serde(default, skip_serializing_if = "Option::is_none")]
1152 pub from: Option<String>,
1153 /// On `work.handoff_requested`: who it is for; absent for anyone.
1154 #[serde(default, skip_serializing_if = "Option::is_none")]
1155 pub to: Option<String>,
1156 #[serde(default, skip_serializing_if = "Option::is_none")]
1157 pub note: Option<String>,
1158}
1159
1160impl ClaimEvent {
1161 pub fn of(claim: &Claim) -> ClaimEvent {
1162 ClaimEvent {
1163 repo_id: claim.repo_id.clone(),
1164 number: claim.issue,
1165 issue: claim.issue,
1166 claim: claim.clone(),
1167 others: None,
1168 took_over: None,
1169 reason: None,
1170 from: None,
1171 to: None,
1172 note: None,
1173 }
1174 }
1175}
1176
1177/// `dependency.changed`: an issue's `blocked_by` changed, or one of the
1178/// issues it is blocked by closed or had its pull request merged.
1179#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1180#[serde(rename_all = "camelCase")]
1181pub struct DependencyChanged {
1182 pub repo_id: String,
1183 /// The issue whose dependencies changed.
1184 pub number: u32,
1185 pub issue: u32,
1186 /// What it is blocked by now.
1187 pub blocked_by: Vec<u32>,
1188 /// Added to `blocked_by`.
1189 pub added: Vec<u32>,
1190 /// Taken out of `blocked_by`.
1191 pub removed: Vec<u32>,
1192 /// Blockers that just closed or had a pull request merged.
1193 pub cleared: Vec<u32>,
1194 /// Those of `blocked_by` still open: none means it is unblocked.
1195 pub remaining: Vec<u32>,
1196 /// When a merge cleared it: the pull request merged.
1197 #[serde(default, skip_serializing_if = "Option::is_none")]
1198 pub merged_pull: Option<u32>,
1199}
1200
1201/// `overlap.detected`: a pull request's latest push makes it change files
1202/// another open or draft pull request changes. Sent once for each side
1203/// when a pair starts overlapping, and again only after it stopped.
1204#[derive(Clone, Debug, Serialize, Deserialize)]
1205#[serde(rename_all = "camelCase")]
1206pub struct OverlapDetected {
1207 pub repo_id: String,
1208 /// The pull request this event is about: `pull`.
1209 pub number: u32,
1210 pub pull: u32,
1211 pub other_pull: u32,
1212 /// The files both change.
1213 pub files: Vec<String>,
1214 #[serde(default, skip_serializing_if = "Option::is_none")]
1215 pub issue: Option<u32>,
1216 #[serde(default, skip_serializing_if = "Option::is_none")]
1217 pub other_issue: Option<u32>,
1218}
1219
1220/// `overlap.detected` between two sessions: the session `session_id` (or
1221/// one of its subagents) touched files that `other_session_id`, active
1222/// and outside its own line of parents and subagents, touched too. Sent
1223/// once for each side when a pair starts overlapping, and again, with the
1224/// new `files`, when another file in common joins. Siblings (subagents
1225/// of one session) are told of each other.
1226#[derive(Clone, Debug, Serialize, Deserialize)]
1227#[serde(rename_all = "camelCase")]
1228pub struct SessionOverlapDetected {
1229 pub repo_id: String,
1230 /// The pull request the session is on, else its issue.
1231 #[serde(default, skip_serializing_if = "Option::is_none")]
1232 pub number: Option<u32>,
1233 pub session_id: String,
1234 pub other_session_id: String,
1235 /// Who runs the other session.
1236 pub other_actor: String,
1237 /// The files both touched.
1238 pub files: Vec<String>,
1239 #[serde(default, skip_serializing_if = "Option::is_none")]
1240 pub issue: Option<u32>,
1241 #[serde(default, skip_serializing_if = "Option::is_none")]
1242 pub other_issue: Option<u32>,
1243}
1244
1245/// The sessions `touched` (session and file pairs, as found) overlaps
1246/// with, leaving out `me` and `family` (its parents and subagents): each
1247/// with the files both touched, by session id, files sorted.
1248pub fn session_overlaps(me: &str, family: &std::collections::HashSet<String>, touched: &[(String, String)]) -> Vec<(String, Vec<String>)> {
1249 let mut found: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
1250 for (session, file) in touched {
1251 if session == me || family.contains(session) {
1252 continue;
1253 }
1254 let files = found.entry(session.clone()).or_default();
1255 if !files.contains(file) {
1256 files.push(file.clone());
1257 }
1258 }
1259 found
1260 .into_iter()
1261 .map(|(session, mut files)| {
1262 files.sort();
1263 (session, files)
1264 })
1265 .collect()
1266}
1267
1268/// A pair of sessions, lower id first, as overlaps are kept.
1269pub fn session_pair(a: &str, b: &str) -> (String, String) {
1270 if a <= b { (a.to_owned(), b.to_owned()) } else { (b.to_owned(), a.to_owned()) }
1271}
1272
1273#[cfg(test)]
1274mod tests {
1275 use std::collections::{HashMap, HashSet};
1276
1277 use super::*;
1278
1279 fn issues(numbers: &[u32]) -> HashSet<u32> {
1280 numbers.iter().copied().collect()
1281 }
1282
1283 #[test]
1284 fn a_session_is_ended_by_whoever_runs_it_or_by_anyone_who_can_write() {
1285 assert!(may_end_session("usr_1", "usr_1", false));
1286 assert!(may_end_session("usr_1", "usr_2", true));
1287 assert!(!may_end_session("usr_1", "usr_2", false));
1288 }
1289
1290 #[test]
1291 fn a_session_ended_by_hand_says_who_ended_it() {
1292 assert_eq!(session_end_reason(true, false, "ana", Some("done")), "done");
1293 assert_eq!(session_end_reason(true, false, "ana", Some(" ")), "ended");
1294 assert_eq!(session_end_reason(true, false, "ana", None), "ended");
1295 assert_eq!(session_end_reason(true, true, "ana", Some("done")), "ended by hand by @ana");
1296 // Someone else's: never in its harness's words.
1297 assert_eq!(session_end_reason(false, false, "bo", Some("done")), "ended by hand by @bo");
1298 assert!(ended_by_hand("bo").starts_with(ENDED_BY_HAND));
1299 }
1300
1301 #[test]
1302 fn a_session_ended_by_hand_or_timed_out_comes_back_when_heard_from() {
1303 assert!(revives(Some("timed_out")));
1304 assert!(revives(Some(&ended_by_hand("bo"))));
1305 assert!(!revives(Some("done")));
1306 assert!(!revives(Some("ended")));
1307 assert!(!revives(None));
1308 }
1309
1310 #[test]
1311 fn a_session_goes_idle_then_ends_without_heartbeats() {
1312 let start = 1_000_000;
1313 assert_eq!(SessionState::at(false, start, start), SessionState::Active);
1314 assert_eq!(SessionState::at(false, start, start + SESSION_IDLE_MS - 1), SessionState::Active);
1315 assert_eq!(SessionState::at(false, start, start + SESSION_IDLE_MS), SessionState::Idle);
1316 assert_eq!(SessionState::at(false, start, start + SESSION_TIMEOUT_MS), SessionState::Ended);
1317 assert_eq!(SessionState::at(true, start, start), SessionState::Ended);
1318 // A clock behind the last heartbeat reads as just heard from.
1319 assert_eq!(SessionState::at(false, start, start - 5), SessionState::Active);
1320 }
1321
1322 #[test]
1323 fn claims_last_thirty_minutes_up_to_four_hours() {
1324 assert_eq!(claim_minutes(None), 30);
1325 assert_eq!(claim_minutes(Some(0)), 1);
1326 assert_eq!(claim_minutes(Some(90)), 90);
1327 assert_eq!(claim_minutes(Some(10_000)), 240);
1328 }
1329
1330 #[test]
1331 fn harnesses_are_named_one_way() {
1332 assert_eq!(Harness::parse("claude-code"), Some(Harness::ClaudeCode));
1333 assert_eq!(Harness::parse("Codex"), Some(Harness::Codex));
1334 assert_eq!(Harness::parse("vim"), None);
1335 assert_eq!(serde_json::to_value(Harness::ClaudeCode).unwrap(), "claude_code");
1336 assert_eq!(serde_json::to_value(ClaimState::HandedOff).unwrap(), "handed_off");
1337 }
1338
1339 #[test]
1340 fn blocked_by_is_issues_of_the_repository_never_itself() {
1341 let graph = HashMap::new();
1342 assert_eq!(check_blocked_by(5, &[3, 4, 3], &issues(&[3, 4, 5]), &graph), Ok(vec![3, 4]));
1343 assert_eq!(check_blocked_by(5, &[], &issues(&[5]), &graph), Ok(vec![]));
1344 assert!(check_blocked_by(5, &[5], &issues(&[5]), &graph).unwrap_err().contains("itself"));
1345 assert!(check_blocked_by(5, &[9], &issues(&[5]), &graph).unwrap_err().contains("#9 is not an issue"));
1346 let many: Vec<u32> = (100..100 + MAX_BLOCKED_BY as u32 + 1).collect();
1347 assert!(check_blocked_by(5, &many, &many.iter().copied().collect(), &graph).is_err());
1348 }
1349
1350 #[test]
1351 fn blocked_by_never_makes_a_cycle() {
1352 // 3 is blocked by 4, which is blocked by 5.
1353 let graph: HashMap<u32, Vec<u32>> = [(3, vec![4]), (4, vec![5])].into_iter().collect();
1354 let all = issues(&[3, 4, 5, 6]);
1355 let refused = check_blocked_by(5, &[3], &all, &graph).unwrap_err();
1356 assert!(refused.contains("#5 → #3 → #4 → #5"), "{refused}");
1357 // A diamond is not a cycle.
1358 let diamond: HashMap<u32, Vec<u32>> = [(3, vec![5]), (4, vec![5])].into_iter().collect();
1359 assert_eq!(check_blocked_by(6, &[3, 4], &all, &diamond), Ok(vec![3, 4]));
1360 // Replacing 5's own blockers is judged without them.
1361 let graph: HashMap<u32, Vec<u32>> = [(5, vec![3]), (3, vec![4])].into_iter().collect();
1362 assert_eq!(check_blocked_by(5, &[4], &all, &graph), Ok(vec![4]));
1363 }
1364
1365 #[test]
1366 fn the_definition_of_done_is_read_from_its_section() {
1367 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";
1368 assert_eq!(definition_of_done(body), ["`npm test` passes.", "Docs say so", "Works offline"]);
1369 assert!(definition_of_done("No section").is_empty());
1370 }
1371
1372 #[test]
1373 fn an_overlap_is_announced_once_until_it_clears() {
1374 assert_eq!(overlap_changes(&[7, 9], &[]), (vec![7, 9], vec![]));
1375 // Pushing again with the same overlaps announces nothing.
1376 assert_eq!(overlap_changes(&[7, 9], &[7, 9]), (vec![], vec![]));
1377 // 9 stopped overlapping: forgotten, so a later overlap is new again.
1378 assert_eq!(overlap_changes(&[7], &[7, 9]), (vec![], vec![9]));
1379 assert_eq!(overlap_changes(&[7, 9], &[7]), (vec![9], vec![]));
1380 }
1381
1382 #[test]
1383 fn a_subagent_is_known_by_its_parent_and_agent_id_however_it_is_spelled() {
1384 assert_eq!(subagent_harness_id("abc123", "agent-def456"), "abc123/agent/def456");
1385 assert_eq!(subagent_harness_id("abc123", "def456"), "abc123/agent/def456");
1386 assert_eq!(Harness::parse("claude_managed_agents"), Some(Harness::ClaudeManagedAgents));
1387 }
1388
1389 #[test]
1390 fn sibling_sessions_of_one_person_hold_claims_of_their_own() {
1391 assert!(claim_is_mine("usr_1", Some("ses_a"), "usr_1", Some("ses_a")));
1392 assert!(!claim_is_mine("usr_1", Some("ses_a"), "usr_1", Some("ses_b")), "a sibling's claim is another's");
1393 assert!(claim_is_mine("usr_1", None, "usr_1", Some("ses_b")), "a claim from no session is the person's");
1394 assert!(claim_is_mine("usr_1", Some("ses_a"), "usr_1", None), "without a session, any of theirs");
1395 assert!(!claim_is_mine("usr_2", Some("ses_a"), "usr_1", Some("ses_a")));
1396 }
1397
1398 #[test]
1399 fn references_are_hashes_followed_by_numbers_and_nothing_else() {
1400 assert_eq!(refs_in("Fix #12 (see #4, #12) and fixes #7."), vec![12, 4, 7]);
1401 assert_eq!(refs_in("a#3 &#39; #12abc #0 #x issue#9 owner/repo#5 #_2"), Vec::<u32>::new());
1402 assert_eq!(refs_in("#1\n#22"), vec![1, 22]);
1403 assert_eq!(refs_in("#12345678"), Vec::<u32>::new());
1404 }
1405
1406 #[test]
1407 fn commits_name_their_sessions_in_trailers() {
1408 let message = "Greet in French\n\nCloses #4\n\nG1t-Session: ses_01abc\ng1t-session: ses_01abc\nG1t-Session: nope\nCo-authored-by: x";
1409 assert_eq!(session_trailers(message), vec!["ses_01abc".to_owned()]);
1410 assert!(session_trailers("no trailer").is_empty());
1411 }
1412
1413 #[test]
1414 fn touches_are_tidied_and_said_once() {
1415 let touch = |kind, value: &str| NewTouch { kind, value: value.into() };
1416 let tidied = tidy_touches(&[
1417 touch(TouchKind::File, "src\\lib.rs"),
1418 touch(TouchKind::File, "src/lib.rs"),
1419 touch(TouchKind::Pull, "#14"),
1420 touch(TouchKind::Issue, "not a number"),
1421 touch(TouchKind::Commit, "ABCDEF1234"),
1422 touch(TouchKind::Commit, "xyz"),
1423 touch(TouchKind::Branch, "refs/heads/fix-4"),
1424 touch(TouchKind::Branch, " "),
1425 ]);
1426 assert_eq!(
1427 tidied,
1428 vec![
1429 touch(TouchKind::File, "src/lib.rs"),
1430 touch(TouchKind::Pull, "14"),
1431 touch(TouchKind::Commit, "abcdef1234"),
1432 touch(TouchKind::Branch, "fix-4"),
1433 ]
1434 );
1435 }
1436
1437 #[test]
1438 fn links_gather_every_way_a_tree_was_tied_and_skip_what_is_unknown() {
1439 let record = |session: &str, kind, number, via| LinkRecord { session_id: session.into(), kind, number, via };
1440 let known: std::collections::HashMap<(LinkKind, u32), (String, String)> = [
1441 ((LinkKind::Issue, 4), ("Greet".to_owned(), "open".to_owned())),
1442 ((LinkKind::Pull, 9), ("Greet in French".to_owned(), "open".to_owned())),
1443 ]
1444 .into_iter()
1445 .collect();
1446 let links = gather_links(
1447 &[
1448 record("ses_root", LinkKind::Issue, 4, LinkVia::Ref),
1449 record("ses_root", LinkKind::Issue, 4, LinkVia::Claim),
1450 record("ses_child", LinkKind::Pull, 9, LinkVia::Branch),
1451 record("ses_child", LinkKind::Pull, 9, LinkVia::Opened),
1452 record("ses_root", LinkKind::Issue, 77, LinkVia::Ref),
1453 ],
1454 &known,
1455 );
1456 assert_eq!(links.len(), 2);
1457 assert_eq!((links[0].kind, links[0].number, links[0].via.clone()), (LinkKind::Pull, 9, vec![LinkVia::Branch, LinkVia::Opened]));
1458 assert_eq!(links[0].session_ids, vec!["ses_child".to_owned()]);
1459 assert_eq!((links[1].kind, links[1].number, links[1].via.clone()), (LinkKind::Issue, 4, vec![LinkVia::Claim, LinkVia::Ref]));
1460 }
1461
1462 #[test]
1463 fn a_session_from_before_subagents_reads_as_a_top_level_one() {
1464 let session: Session = serde_json::from_value(serde_json::json!({
1465 "id": "ses_1", "repoId": "rep_1", "issue": null, "pull": null, "branch": null,
1466 "actor": { "id": "usr_1", "username": "ana" }, "harness": "claude_code",
1467 "harnessSessionId": null, "title": null, "state": "active", "headCommit": null,
1468 "startedAt": "", "lastSeenAt": "", "endedAt": null, "endReason": null
1469 }))
1470 .unwrap();
1471 assert_eq!((session.parent_id, session.subagents), (None, 0));
1472 }
1473
1474 #[test]
1475 fn events_name_the_issue_or_pull_request_they_are_about() {
1476 let session: Session = serde_json::from_value(serde_json::json!({
1477 "id": "ses_1", "repoId": "rep_1", "issue": 4, "pull": 9, "branch": null,
1478 "actor": { "id": "usr_1", "username": "ana" }, "harness": "claude_code",
1479 "harnessSessionId": null, "title": null, "state": "active", "headCommit": null,
1480 "startedAt": "", "lastSeenAt": "", "endedAt": null, "endReason": null
1481 }))
1482 .unwrap();
1483 let event = serde_json::to_value(SessionEvent::of(&session)).unwrap();
1484 assert_eq!((event["number"].as_u64(), event["issue"].as_u64()), (Some(9), Some(4)));
1485 }
1486}