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