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,419 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.
714#[derive(Debug, Serialize, Deserialize)]
715pub struct EndSessionArgs {
716 pub actor: User,
717 pub repo: RepoPath,
718 pub id: String,
719 #[serde(default)]
720 pub reason: Option<String>,
721 /// What it reported when it finished.
722 #[serde(default)]
723 pub result: Option<String>,
724}
725
726/// `get_work_session`: a session and its entries after `after_seq`.
727/// Returns `Outcome<SessionDetail>`.
728#[derive(Debug, Serialize, Deserialize)]
729pub struct WorkSessionArgs {
730 pub viewer: Viewer,
731 pub repo: RepoPath,
732 pub id: String,
733 #[serde(default)]
734 pub after_seq: u32,
735}
736
737#[derive(Clone, Debug, Serialize, Deserialize)]
738pub struct SessionUpdate {
739 pub id: String,
740 pub kind: String,
741 pub data: serde_json::Value,
742 pub at: String,
743}
744
745#[derive(Clone, Debug, Serialize, Deserialize)]
746pub struct SessionDetail {
747 /// Oldest pending coordination updates, at most 100. Reading does not acknowledge them.
748 #[serde(default)]
749 pub updates: Vec<SessionUpdate>,
750 pub session: Session,
751 /// Oldest first, numbered within the session.
752 pub entries: Vec<SessionEntry>,
753 /// Its subagents, and theirs, in the order they started: each names its
754 /// parent in `parent_id`.
755 #[serde(default)]
756 pub children: Vec<Session>,
757 /// What it and its subagents touched, in the order first said.
758 #[serde(default)]
759 pub touched: Vec<SessionTouch>,
760 /// The issues and pull requests it and its subagents worked on.
761 #[serde(default)]
762 pub links: Vec<SessionLink>,
763 /// The skills it and its subagents used, by name.
764 #[serde(default)]
765 pub skills: Vec<SessionSkill>,
766 /// For a subagent: the session at the top of its tree.
767 #[serde(default)]
768 pub root: Option<Session>,
769 /// The viewer is not a member of the repository's workspace: they see
770 /// that the session runs, who runs it and what it is tied to, and
771 /// nothing of what it recorded, touched or was asked.
772 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
773 pub limited: bool,
774}
775
776/// `pull_sessions`: the harness sessions tied to some of a repository's
777/// pull requests (`numbers`, at most 100) any way a session is tied to
778/// one, for Home's marks on what landed. Returns
779/// `Outcome<Vec<PullSessions>>`, one entry for each pull request that has
780/// any, its top-level sessions first: a subagent counts as the session
781/// that started it.
782#[derive(Debug, Serialize, Deserialize)]
783pub struct PullSessionsArgs {
784 pub viewer: Viewer,
785 pub repo: RepoPath,
786 pub numbers: Vec<u32>,
787}
788
789/// The sessions tied to one pull request.
790#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
791#[serde(rename_all = "camelCase")]
792pub struct PullSessions {
793 pub number: u32,
794 pub sessions: Vec<SessionRef>,
795}
796
797/// A session, as a mark on something it made: its id and harness.
798#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
799#[serde(rename_all = "camelCase")]
800pub struct SessionRef {
801 pub id: String,
802 pub harness: Harness,
803}
804
805/// `list_work_sessions`: a repository's sessions, most recently heard from
806/// first. Returns `Outcome<Vec<Session>>`. Subagents are left out unless
807/// `subagents`; with `issue` or `pull`, the sessions tied to it any way
808/// (attached, a claim, its branch or commits, a trailer, opened, named)
809/// are listed, and with `subagents` their subagents too.
810#[derive(Debug, Serialize, Deserialize)]
811pub struct ListWorkSessionsArgs {
812 pub viewer: Viewer,
813 pub repo: RepoPath,
814 #[serde(default)]
815 pub state: Option<SessionState>,
816 #[serde(default)]
817 pub issue: Option<u32>,
818 #[serde(default)]
819 pub pull: Option<u32>,
820 #[serde(default)]
821 pub subagents: bool,
822 /// Only sessions that touched this file, or any under it when it ends
823 /// in `/`.
824 #[serde(default)]
825 pub path: Option<String>,
826}
827
828/// An entry to record in a session. `key` makes recording it again a no-op.
829#[derive(Clone, Debug, Serialize, Deserialize)]
830pub struct KeyedSessionEntry {
831 #[serde(flatten)]
832 pub entry: NewSessionEntry,
833 #[serde(default)]
834 pub key: Option<String>,
835}
836
837/// `record_session_entries`. Returns `Outcome<Recorded>`.
838#[derive(Debug, Serialize, Deserialize)]
839pub struct RecordEntriesArgs {
840 /// Exact update IDs this session has considered. Repeated acknowledgements are harmless.
841 #[serde(default)]
842 pub acknowledge: Vec<String>,
843 pub actor: User,
844 pub repo: RepoPath,
845 pub id: String,
846 pub entries: Vec<KeyedSessionEntry>,
847 /// What it touched: files, commits, branches, pull requests opened,
848 /// issues named. Recorded once each; pull requests and issues tie the
849 /// session to them.
850 #[serde(default)]
851 pub touched: Vec<NewTouch>,
852}
853
854#[derive(Clone, Debug, Default, Serialize, Deserialize)]
855pub struct Recorded {
856 /// Entries newly recorded.
857 pub recorded: u32,
858 /// Entries skipped because one with the same key was recorded before.
859 pub duplicates: u32,
860 /// Of the `acknowledge` IDs given, how many name an update of this
861 /// session (acknowledged now or before). Fewer than given means some
862 /// IDs were mistyped or belong to another session.
863 #[serde(default)]
864 pub acknowledged: u32,
865}
866
867/// `claim_issue`. Returns `Outcome<Claimed>`. Claiming an issue you hold
868/// renews your claim (and changes its note or session when given). With
869/// no number and a `title`, it opens the issue first and attaches the
870/// session to it; `Claimed::issue` is the issue it opened.
871#[derive(Clone, Debug, Serialize, Deserialize)]
872pub struct ClaimArgs {
873 pub actor: User,
874 pub repo: RepoPath,
875 /// The issue to claim; 0 with `title` to open one and claim it.
876 #[serde(default)]
877 pub number: u32,
878 /// Without a number: the title of a new issue to open and claim. The
879 /// session named by `session_id` is attached to it.
880 #[serde(default)]
881 pub title: Option<String>,
882 /// Without a number: the new issue's description.
883 #[serde(default)]
884 pub body: Option<String>,
885 #[serde(default)]
886 pub session_id: Option<String>,
887 #[serde(default)]
888 pub note: Option<String>,
889 #[serde(default)]
890 pub ttl_minutes: Option<u32>,
891}
892
893#[derive(Clone, Debug, Serialize, Deserialize)]
894#[serde(rename_all = "camelCase")]
895pub struct Claimed {
896 pub claim: Claim,
897 /// Other active claims on the issue: someone else is on it too.
898 pub others: Vec<Claim>,
899 /// What the issue is blocked by, and where each stands.
900 pub blocked_by: Vec<Blocker>,
901 /// The issue the claim opened, when it was asked to open one (a
902 /// `title` instead of a number).
903 #[serde(default, skip_serializing_if = "Option::is_none")]
904 pub issue: Option<Issue>,
905}
906
907/// `release_claim`: gives up your active claim on an issue. Returns
908/// `Outcome<Claim>`.
909#[derive(Debug, Serialize, Deserialize)]
910pub struct ReleaseArgs {
911 pub actor: User,
912 pub repo: RepoPath,
913 pub number: u32,
914 #[serde(default)]
915 pub reason: Option<String>,
916 /// The session whose claim it is, when the caller has several.
917 #[serde(default)]
918 pub session_id: Option<String>,
919}
920
921/// `hand_off_issue`: ends your active claim on an issue as handed off, to
922/// someone (`to`, a username) or anyone, with a note on where it stands.
923/// Returns `Outcome<Claim>`.
924#[derive(Debug, Serialize, Deserialize)]
925pub struct HandOffArgs {
926 pub actor: User,
927 pub repo: RepoPath,
928 pub number: u32,
929 #[serde(default)]
930 pub to: Option<String>,
931 pub note: String,
932 /// The session whose claim it is, when the caller has several.
933 #[serde(default)]
934 pub session_id: Option<String>,
935}
936
937/// `list_claims`: a repository's claims, newest first; active ones unless
938/// `state` says otherwise. Returns `Outcome<Vec<Claim>>`.
939#[derive(Debug, Serialize, Deserialize)]
940pub struct ListClaimsArgs {
941 pub viewer: Viewer,
942 pub repo: RepoPath,
943 #[serde(default)]
944 pub state: Option<ClaimState>,
945 #[serde(default)]
946 pub issue: Option<u32>,
947}
948
949/// `get_work`: everything an agent needs to pick up an issue. Returns
950/// `Outcome<WorkContext>`.
951#[derive(Debug, Serialize, Deserialize)]
952pub struct GetWorkArgs {
953 pub viewer: Viewer,
954 pub repo: RepoPath,
955 pub number: u32,
956}
957
958/// An issue in brief.
959#[derive(Clone, Debug, Serialize, Deserialize)]
960pub struct IssueBrief {
961 pub number: u32,
962 pub title: String,
963 pub state: State,
964}
965
966/// A pull request in brief.
967#[derive(Clone, Debug, Serialize, Deserialize)]
968pub struct PullBrief {
969 pub number: u32,
970 pub title: String,
971 pub status: PullStatus,
972}
973
974/// An issue another is blocked by, and where it stands.
975#[derive(Clone, Debug, Serialize, Deserialize)]
976pub struct Blocker {
977 pub number: u32,
978 pub title: String,
979 pub state: State,
980 /// Active claims on it.
981 pub claims: Vec<Claim>,
982 /// Its pull requests.
983 pub pulls: Vec<PullBrief>,
984}
985
986/// A pull request's place in the merge queue.
987#[derive(Clone, Debug, Serialize, Deserialize)]
988pub struct QueuePlace {
989 pub state: QueueState,
990 /// 1 for the front of the queue.
991 pub position: u32,
992}
993
994/// One of an issue's pull requests, with where it stands.
995#[derive(Clone, Debug, Serialize, Deserialize)]
996#[serde(rename_all = "camelCase")]
997pub struct WorkPull {
998 pub number: u32,
999 pub title: String,
1000 pub status: PullStatus,
1001 pub branch: Option<String>,
1002 pub head_commit: Option<String>,
1003 pub author: Principal,
1004 /// `failed` when the merge queue took it out, until its head moves.
1005 pub check_status: Option<CheckStatus>,
1006 /// The checks the branch it merges into requires, as they stand on its
1007 /// head.
1008 pub required_checks: Vec<RequiredCheck>,
1009 pub mergeable: Mergeable,
1010 /// Other pull requests in progress changing the same files.
1011 pub overlaps: Vec<Overlap>,
1012 /// Its place in the merge queue, while it is in it.
1013 pub queue: Option<QueuePlace>,
1014}
1015
1016/// Everything about an issue an agent needs to pick it up: the one call to
1017/// make before starting.
1018#[derive(Clone, Debug, Serialize, Deserialize)]
1019#[serde(rename_all = "camelCase")]
1020pub struct WorkContext {
1021 pub issue: Issue,
1022 /// The items under its "Definition of done" heading.
1023 pub definition_of_done: Vec<String>,
1024 /// The checks every pull request into the default branch must pass.
1025 pub required_checks: Vec<String>,
1026 /// Active claims on it.
1027 pub claims: Vec<Claim>,
1028 /// Sessions on it or its pull requests, most recently heard from first.
1029 pub sessions: Vec<Session>,
1030 pub pulls: Vec<WorkPull>,
1031 /// What it is blocked by, and where each stands.
1032 pub blocked_by: Vec<Blocker>,
1033 /// The issues it blocks.
1034 pub blocks: Vec<IssueBrief>,
1035 /// Decision records on it and its pull requests, newest first.
1036 #[serde(default)]
1037 pub decisions: Vec<crate::decisions::Decision>,
1038 /// The latest `integration_failure` decision on it or its pull
1039 /// requests, if any: which combination failed, and the check.
1040 #[serde(default)]
1041 pub last_integration_failure: Option<crate::decisions::Decision>,
1042 /// The latest coordination events about it, newest first.
1043 pub recent: Vec<Event>,
1044}
1045
1046// --- Events -----------------------------------------------------------------
1047
1048/// `session.started` and `session.ended`.
1049#[derive(Clone, Debug, Serialize, Deserialize)]
1050#[serde(rename_all = "camelCase")]
1051pub struct SessionEvent {
1052 pub repo_id: String,
1053 /// The pull request it works on, else its issue.
1054 #[serde(default, skip_serializing_if = "Option::is_none")]
1055 pub number: Option<u32>,
1056 #[serde(default, skip_serializing_if = "Option::is_none")]
1057 pub issue: Option<u32>,
1058 pub session: Session,
1059}
1060
1061impl SessionEvent {
1062 pub fn of(session: &Session) -> SessionEvent {
1063 SessionEvent {
1064 repo_id: session.repo_id.clone(),
1065 number: session.pull.or(session.issue),
1066 issue: session.issue,
1067 session: session.clone(),
1068 }
1069 }
1070}
1071
1072/// The most often a session's recording is announced as
1073/// `session.progressed`, so its open pages follow it without hearing of
1074/// every tool call.
1075pub const PROGRESS_EVERY_MS: u64 = 5_000;
1076
1077/// `session.progressed`: a session (or a subagent of it) recorded more, for
1078/// the pages showing it. Says where, never what.
1079#[derive(Clone, Debug, Serialize, Deserialize)]
1080#[serde(rename_all = "camelCase")]
1081pub struct SessionProgressed {
1082 pub repo_id: String,
1083 /// The pull request it works on, else its issue.
1084 #[serde(default, skip_serializing_if = "Option::is_none")]
1085 pub number: Option<u32>,
1086 pub session_id: String,
1087 /// The session at the top of its tree, when it is a subagent.
1088 #[serde(default, skip_serializing_if = "Option::is_none")]
1089 pub root_id: Option<String>,
1090}
1091
1092/// `work.claimed`, `work.released`, `work.claim_expired` and
1093/// `work.handoff_requested`: `number` and `issue` are the issue claimed.
1094#[derive(Clone, Debug, Serialize, Deserialize)]
1095#[serde(rename_all = "camelCase")]
1096pub struct ClaimEvent {
1097 pub repo_id: String,
1098 pub number: u32,
1099 pub issue: u32,
1100 pub claim: Claim,
1101 /// On `work.claimed`: the other active claims on the issue.
1102 #[serde(default, skip_serializing_if = "Option::is_none")]
1103 pub others: Option<Vec<Claim>>,
1104 /// On `work.claimed`, when it takes over a claim handed off.
1105 #[serde(default, skip_serializing_if = "Option::is_none")]
1106 pub took_over: Option<String>,
1107 /// On `work.released`.
1108 #[serde(default, skip_serializing_if = "Option::is_none")]
1109 pub reason: Option<String>,
1110 /// On `work.handoff_requested`: who handed it off.
1111 #[serde(default, skip_serializing_if = "Option::is_none")]
1112 pub from: Option<String>,
1113 /// On `work.handoff_requested`: who it is for; absent for anyone.
1114 #[serde(default, skip_serializing_if = "Option::is_none")]
1115 pub to: Option<String>,
1116 #[serde(default, skip_serializing_if = "Option::is_none")]
1117 pub note: Option<String>,
1118}
1119
1120impl ClaimEvent {
1121 pub fn of(claim: &Claim) -> ClaimEvent {
1122 ClaimEvent {
1123 repo_id: claim.repo_id.clone(),
1124 number: claim.issue,
1125 issue: claim.issue,
1126 claim: claim.clone(),
1127 others: None,
1128 took_over: None,
1129 reason: None,
1130 from: None,
1131 to: None,
1132 note: None,
1133 }
1134 }
1135}
1136
1137/// `dependency.changed`: an issue's `blocked_by` changed, or one of the
1138/// issues it is blocked by closed or had its pull request merged.
1139#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1140#[serde(rename_all = "camelCase")]
1141pub struct DependencyChanged {
1142 pub repo_id: String,
1143 /// The issue whose dependencies changed.
1144 pub number: u32,
1145 pub issue: u32,
1146 /// What it is blocked by now.
1147 pub blocked_by: Vec<u32>,
1148 /// Added to `blocked_by`.
1149 pub added: Vec<u32>,
1150 /// Taken out of `blocked_by`.
1151 pub removed: Vec<u32>,
1152 /// Blockers that just closed or had a pull request merged.
1153 pub cleared: Vec<u32>,
1154 /// Those of `blocked_by` still open: none means it is unblocked.
1155 pub remaining: Vec<u32>,
1156 /// When a merge cleared it: the pull request merged.
1157 #[serde(default, skip_serializing_if = "Option::is_none")]
1158 pub merged_pull: Option<u32>,
1159}
1160
1161/// `overlap.detected`: a pull request's latest push makes it change files
1162/// another open or draft pull request changes. Sent once for each side
1163/// when a pair starts overlapping, and again only after it stopped.
1164#[derive(Clone, Debug, Serialize, Deserialize)]
1165#[serde(rename_all = "camelCase")]
1166pub struct OverlapDetected {
1167 pub repo_id: String,
1168 /// The pull request this event is about: `pull`.
1169 pub number: u32,
1170 pub pull: u32,
1171 pub other_pull: u32,
1172 /// The files both change.
1173 pub files: Vec<String>,
1174 #[serde(default, skip_serializing_if = "Option::is_none")]
1175 pub issue: Option<u32>,
1176 #[serde(default, skip_serializing_if = "Option::is_none")]
1177 pub other_issue: Option<u32>,
1178}
1179
1180/// `overlap.detected` between two sessions: the session `session_id` (or
1181/// one of its subagents) touched files that `other_session_id`, active
1182/// and outside its own line of parents and subagents, touched too. Sent
1183/// once for each side when a pair starts overlapping, and again, with the
1184/// new `files`, when another file in common joins. Siblings (subagents
1185/// of one session) are told of each other.
1186#[derive(Clone, Debug, Serialize, Deserialize)]
1187#[serde(rename_all = "camelCase")]
1188pub struct SessionOverlapDetected {
1189 pub repo_id: String,
1190 /// The pull request the session is on, else its issue.
1191 #[serde(default, skip_serializing_if = "Option::is_none")]
1192 pub number: Option<u32>,
1193 pub session_id: String,
1194 pub other_session_id: String,
1195 /// Who runs the other session.
1196 pub other_actor: String,
1197 /// The files both touched.
1198 pub files: Vec<String>,
1199 #[serde(default, skip_serializing_if = "Option::is_none")]
1200 pub issue: Option<u32>,
1201 #[serde(default, skip_serializing_if = "Option::is_none")]
1202 pub other_issue: Option<u32>,
1203}
1204
1205/// The sessions `touched` (session and file pairs, as found) overlaps
1206/// with, leaving out `me` and `family` (its parents and subagents): each
1207/// with the files both touched, by session id, files sorted.
1208pub fn session_overlaps(me: &str, family: &std::collections::HashSet<String>, touched: &[(String, String)]) -> Vec<(String, Vec<String>)> {
1209 let mut found: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
1210 for (session, file) in touched {
1211 if session == me || family.contains(session) {
1212 continue;
1213 }
1214 let files = found.entry(session.clone()).or_default();
1215 if !files.contains(file) {
1216 files.push(file.clone());
1217 }
1218 }
1219 found
1220 .into_iter()
1221 .map(|(session, mut files)| {
1222 files.sort();
1223 (session, files)
1224 })
1225 .collect()
1226}
1227
1228/// A pair of sessions, lower id first, as overlaps are kept.
1229pub fn session_pair(a: &str, b: &str) -> (String, String) {
1230 if a <= b { (a.to_owned(), b.to_owned()) } else { (b.to_owned(), a.to_owned()) }
1231}
1232
1233#[cfg(test)]
1234mod tests {
1235 use std::collections::{HashMap, HashSet};
1236
1237 use super::*;
1238
1239 fn issues(numbers: &[u32]) -> HashSet<u32> {
1240 numbers.iter().copied().collect()
1241 }
1242
1243 #[test]
1244 fn a_session_goes_idle_then_ends_without_heartbeats() {
1245 let start = 1_000_000;
1246 assert_eq!(SessionState::at(false, start, start), SessionState::Active);
1247 assert_eq!(SessionState::at(false, start, start + SESSION_IDLE_MS - 1), SessionState::Active);
1248 assert_eq!(SessionState::at(false, start, start + SESSION_IDLE_MS), SessionState::Idle);
1249 assert_eq!(SessionState::at(false, start, start + SESSION_TIMEOUT_MS), SessionState::Ended);
1250 assert_eq!(SessionState::at(true, start, start), SessionState::Ended);
1251 // A clock behind the last heartbeat reads as just heard from.
1252 assert_eq!(SessionState::at(false, start, start - 5), SessionState::Active);
1253 }
1254
1255 #[test]
1256 fn claims_last_thirty_minutes_up_to_four_hours() {
1257 assert_eq!(claim_minutes(None), 30);
1258 assert_eq!(claim_minutes(Some(0)), 1);
1259 assert_eq!(claim_minutes(Some(90)), 90);
1260 assert_eq!(claim_minutes(Some(10_000)), 240);
1261 }
1262
1263 #[test]
1264 fn harnesses_are_named_one_way() {
1265 assert_eq!(Harness::parse("claude-code"), Some(Harness::ClaudeCode));
1266 assert_eq!(Harness::parse("Codex"), Some(Harness::Codex));
1267 assert_eq!(Harness::parse("vim"), None);
1268 assert_eq!(serde_json::to_value(Harness::ClaudeCode).unwrap(), "claude_code");
1269 assert_eq!(serde_json::to_value(ClaimState::HandedOff).unwrap(), "handed_off");
1270 }
1271
1272 #[test]
1273 fn blocked_by_is_issues_of_the_repository_never_itself() {
1274 let graph = HashMap::new();
1275 assert_eq!(check_blocked_by(5, &[3, 4, 3], &issues(&[3, 4, 5]), &graph), Ok(vec![3, 4]));
1276 assert_eq!(check_blocked_by(5, &[], &issues(&[5]), &graph), Ok(vec![]));
1277 assert!(check_blocked_by(5, &[5], &issues(&[5]), &graph).unwrap_err().contains("itself"));
1278 assert!(check_blocked_by(5, &[9], &issues(&[5]), &graph).unwrap_err().contains("#9 is not an issue"));
1279 let many: Vec<u32> = (100..100 + MAX_BLOCKED_BY as u32 + 1).collect();
1280 assert!(check_blocked_by(5, &many, &many.iter().copied().collect(), &graph).is_err());
1281 }
1282
1283 #[test]
1284 fn blocked_by_never_makes_a_cycle() {
1285 // 3 is blocked by 4, which is blocked by 5.
1286 let graph: HashMap<u32, Vec<u32>> = [(3, vec![4]), (4, vec![5])].into_iter().collect();
1287 let all = issues(&[3, 4, 5, 6]);
1288 let refused = check_blocked_by(5, &[3], &all, &graph).unwrap_err();
1289 assert!(refused.contains("#5 → #3 → #4 → #5"), "{refused}");
1290 // A diamond is not a cycle.
1291 let diamond: HashMap<u32, Vec<u32>> = [(3, vec![5]), (4, vec![5])].into_iter().collect();
1292 assert_eq!(check_blocked_by(6, &[3, 4], &all, &diamond), Ok(vec![3, 4]));
1293 // Replacing 5's own blockers is judged without them.
1294 let graph: HashMap<u32, Vec<u32>> = [(5, vec![3]), (3, vec![4])].into_iter().collect();
1295 assert_eq!(check_blocked_by(5, &[4], &all, &graph), Ok(vec![4]));
1296 }
1297
1298 #[test]
1299 fn the_definition_of_done_is_read_from_its_section() {
1300 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";
1301 assert_eq!(definition_of_done(body), ["`npm test` passes.", "Docs say so", "Works offline"]);
1302 assert!(definition_of_done("No section").is_empty());
1303 }
1304
1305 #[test]
1306 fn an_overlap_is_announced_once_until_it_clears() {
1307 assert_eq!(overlap_changes(&[7, 9], &[]), (vec![7, 9], vec![]));
1308 // Pushing again with the same overlaps announces nothing.
1309 assert_eq!(overlap_changes(&[7, 9], &[7, 9]), (vec![], vec![]));
1310 // 9 stopped overlapping: forgotten, so a later overlap is new again.
1311 assert_eq!(overlap_changes(&[7], &[7, 9]), (vec![], vec![9]));
1312 assert_eq!(overlap_changes(&[7, 9], &[7]), (vec![9], vec![]));
1313 }
1314
1315 #[test]
1316 fn a_subagent_is_known_by_its_parent_and_agent_id_however_it_is_spelled() {
1317 assert_eq!(subagent_harness_id("abc123", "agent-def456"), "abc123/agent/def456");
1318 assert_eq!(subagent_harness_id("abc123", "def456"), "abc123/agent/def456");
1319 assert_eq!(Harness::parse("claude_managed_agents"), Some(Harness::ClaudeManagedAgents));
1320 }
1321
1322 #[test]
1323 fn sibling_sessions_of_one_person_hold_claims_of_their_own() {
1324 assert!(claim_is_mine("usr_1", Some("ses_a"), "usr_1", Some("ses_a")));
1325 assert!(!claim_is_mine("usr_1", Some("ses_a"), "usr_1", Some("ses_b")), "a sibling's claim is another's");
1326 assert!(claim_is_mine("usr_1", None, "usr_1", Some("ses_b")), "a claim from no session is the person's");
1327 assert!(claim_is_mine("usr_1", Some("ses_a"), "usr_1", None), "without a session, any of theirs");
1328 assert!(!claim_is_mine("usr_2", Some("ses_a"), "usr_1", Some("ses_a")));
1329 }
1330
1331 #[test]
1332 fn references_are_hashes_followed_by_numbers_and_nothing_else() {
1333 assert_eq!(refs_in("Fix #12 (see #4, #12) and fixes #7."), vec![12, 4, 7]);
1334 assert_eq!(refs_in("a#3 &#39; #12abc #0 #x issue#9 owner/repo#5 #_2"), Vec::<u32>::new());
1335 assert_eq!(refs_in("#1\n#22"), vec![1, 22]);
1336 assert_eq!(refs_in("#12345678"), Vec::<u32>::new());
1337 }
1338
1339 #[test]
1340 fn commits_name_their_sessions_in_trailers() {
1341 let message = "Greet in French\n\nCloses #4\n\nG1t-Session: ses_01abc\ng1t-session: ses_01abc\nG1t-Session: nope\nCo-authored-by: x";
1342 assert_eq!(session_trailers(message), vec!["ses_01abc".to_owned()]);
1343 assert!(session_trailers("no trailer").is_empty());
1344 }
1345
1346 #[test]
1347 fn touches_are_tidied_and_said_once() {
1348 let touch = |kind, value: &str| NewTouch { kind, value: value.into() };
1349 let tidied = tidy_touches(&[
1350 touch(TouchKind::File, "src\\lib.rs"),
1351 touch(TouchKind::File, "src/lib.rs"),
1352 touch(TouchKind::Pull, "#14"),
1353 touch(TouchKind::Issue, "not a number"),
1354 touch(TouchKind::Commit, "ABCDEF1234"),
1355 touch(TouchKind::Commit, "xyz"),
1356 touch(TouchKind::Branch, "refs/heads/fix-4"),
1357 touch(TouchKind::Branch, " "),
1358 ]);
1359 assert_eq!(
1360 tidied,
1361 vec![
1362 touch(TouchKind::File, "src/lib.rs"),
1363 touch(TouchKind::Pull, "14"),
1364 touch(TouchKind::Commit, "abcdef1234"),
1365 touch(TouchKind::Branch, "fix-4"),
1366 ]
1367 );
1368 }
1369
1370 #[test]
1371 fn links_gather_every_way_a_tree_was_tied_and_skip_what_is_unknown() {
1372 let record = |session: &str, kind, number, via| LinkRecord { session_id: session.into(), kind, number, via };
1373 let known: std::collections::HashMap<(LinkKind, u32), (String, String)> = [
1374 ((LinkKind::Issue, 4), ("Greet".to_owned(), "open".to_owned())),
1375 ((LinkKind::Pull, 9), ("Greet in French".to_owned(), "open".to_owned())),
1376 ]
1377 .into_iter()
1378 .collect();
1379 let links = gather_links(
1380 &[
1381 record("ses_root", LinkKind::Issue, 4, LinkVia::Ref),
1382 record("ses_root", LinkKind::Issue, 4, LinkVia::Claim),
1383 record("ses_child", LinkKind::Pull, 9, LinkVia::Branch),
1384 record("ses_child", LinkKind::Pull, 9, LinkVia::Opened),
1385 record("ses_root", LinkKind::Issue, 77, LinkVia::Ref),
1386 ],
1387 &known,
1388 );
1389 assert_eq!(links.len(), 2);
1390 assert_eq!((links[0].kind, links[0].number, links[0].via.clone()), (LinkKind::Pull, 9, vec![LinkVia::Branch, LinkVia::Opened]));
1391 assert_eq!(links[0].session_ids, vec!["ses_child".to_owned()]);
1392 assert_eq!((links[1].kind, links[1].number, links[1].via.clone()), (LinkKind::Issue, 4, vec![LinkVia::Claim, LinkVia::Ref]));
1393 }
1394
1395 #[test]
1396 fn a_session_from_before_subagents_reads_as_a_top_level_one() {
1397 let session: Session = serde_json::from_value(serde_json::json!({
1398 "id": "ses_1", "repoId": "rep_1", "issue": null, "pull": null, "branch": null,
1399 "actor": { "id": "usr_1", "username": "ana" }, "harness": "claude_code",
1400 "harnessSessionId": null, "title": null, "state": "active", "headCommit": null,
1401 "startedAt": "", "lastSeenAt": "", "endedAt": null, "endReason": null
1402 }))
1403 .unwrap();
1404 assert_eq!((session.parent_id, session.subagents), (None, 0));
1405 }
1406
1407 #[test]
1408 fn events_name_the_issue_or_pull_request_they_are_about() {
1409 let session: Session = serde_json::from_value(serde_json::json!({
1410 "id": "ses_1", "repoId": "rep_1", "issue": 4, "pull": 9, "branch": null,
1411 "actor": { "id": "usr_1", "username": "ana" }, "harness": "claude_code",
1412 "harnessSessionId": null, "title": null, "state": "active", "headCommit": null,
1413 "startedAt": "", "lastSeenAt": "", "endedAt": null, "endReason": null
1414 }))
1415 .unwrap();
1416 let event = serde_json::to_value(SessionEvent::of(&session)).unwrap();
1417 assert_eq!((event["number"].as_u64(), event["issue"].as_u64()), (Some(9), Some(4)));
1418 }
1419}