Skip to content

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

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