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.

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