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