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