Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'
44 files+1438−2528 files not counted0/44 viewed
| 1646 | 1646 | "g1t-rules", | |
| 1647 | 1647 | "getrandom 0.2.17", | |
| 1648 | 1648 | "hex", | |
| 1649 | + | "rusqlite", | |
| 1649 | 1650 | "serde", | |
| 1650 | 1651 | "serde_json", | |
| 1651 | 1652 | "sha2 0.10.9", |
| 1 | + | //! Coordinating work over REST and MCP: sessions of people's own harnesses | |
| 2 | + | //! (the MCP `session` tool), and claims and an issue's whole context | |
| 3 | + | //! (actions of the `issue` tool). | |
| 4 | + | //! | |
| 5 | + | //! The work service keeps them and decides who may see and change them | |
| 6 | + | //! (`g1t_contracts::work::coordination`): anyone who can read the | |
| 7 | + | //! repository reads them; taking part needs a confirmed email address and a | |
| 8 | + | //! role that may open issues and pull requests; only the person whose | |
| 9 | + | //! token started a session, or made a claim, changes it. | |
| 10 | + | ||
| 11 | + | use g1t_contracts::work::*; | |
| 12 | + | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 13 | + | use serde_json::{Value, json}; | |
| 14 | + | use worker::Result; | |
| 15 | + | ||
| 16 | + | use crate::operations::{Services, repo_path}; | |
| 17 | + | ||
| 18 | + | /// One operation on sessions, claims or an issue's context. | |
| 19 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 20 | + | pub enum CoordinationOp { | |
| 21 | + | StartSession, | |
| 22 | + | UpdateSession, | |
| 23 | + | EndSession, | |
| 24 | + | GetSession, | |
| 25 | + | ListSessions, | |
| 26 | + | RecordSessionEntries, | |
| 27 | + | ClaimIssue, | |
| 28 | + | ReleaseClaim, | |
| 29 | + | HandOffIssue, | |
| 30 | + | ListClaims, | |
| 31 | + | GetWork, | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | impl CoordinationOp { | |
| 35 | + | /// Every one: `Op::ALL` lists each as `Op::Coordination(…)`, which a | |
| 36 | + | /// test checks against this. | |
| 37 | + | pub const ALL: [CoordinationOp; 11] = [ | |
| 38 | + | CoordinationOp::StartSession, | |
| 39 | + | CoordinationOp::UpdateSession, | |
| 40 | + | CoordinationOp::EndSession, | |
| 41 | + | CoordinationOp::GetSession, | |
| 42 | + | CoordinationOp::ListSessions, | |
| 43 | + | CoordinationOp::RecordSessionEntries, | |
| 44 | + | CoordinationOp::ClaimIssue, | |
| 45 | + | CoordinationOp::ReleaseClaim, | |
| 46 | + | CoordinationOp::HandOffIssue, | |
| 47 | + | CoordinationOp::ListClaims, | |
| 48 | + | CoordinationOp::GetWork, | |
| 49 | + | ]; | |
| 50 | + | ||
| 51 | + | pub fn name(self) -> &'static str { | |
| 52 | + | match self { | |
| 53 | + | CoordinationOp::StartSession => "start_session", | |
| 54 | + | CoordinationOp::UpdateSession => "update_session", | |
| 55 | + | CoordinationOp::EndSession => "end_session", | |
| 56 | + | CoordinationOp::GetSession => "get_session", | |
| 57 | + | CoordinationOp::ListSessions => "list_sessions", | |
| 58 | + | CoordinationOp::RecordSessionEntries => "record_session_entries", | |
| 59 | + | CoordinationOp::ClaimIssue => "claim_issue", | |
| 60 | + | CoordinationOp::ReleaseClaim => "release_claim", | |
| 61 | + | CoordinationOp::HandOffIssue => "hand_off_issue", | |
| 62 | + | CoordinationOp::ListClaims => "list_claims", | |
| 63 | + | CoordinationOp::GetWork => "get_work", | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /// For the API reference. | |
| 68 | + | pub fn title(self) -> &'static str { | |
| 69 | + | match self { | |
| 70 | + | CoordinationOp::StartSession => "Start a session", | |
| 71 | + | CoordinationOp::UpdateSession => "Update a session", | |
| 72 | + | CoordinationOp::EndSession => "End a session", | |
| 73 | + | CoordinationOp::GetSession => "Get a session", | |
| 74 | + | CoordinationOp::ListSessions => "List sessions", | |
| 75 | + | CoordinationOp::RecordSessionEntries => "Record entries in a session", | |
| 76 | + | CoordinationOp::ClaimIssue => "Claim an issue", | |
| 77 | + | CoordinationOp::ReleaseClaim => "Release a claim", | |
| 78 | + | CoordinationOp::HandOffIssue => "Hand off an issue", | |
| 79 | + | CoordinationOp::ListClaims => "List claims", | |
| 80 | + | CoordinationOp::GetWork => "Get an issue's work context", | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /// Whether it only reads. | |
| 85 | + | pub fn reads(self) -> bool { | |
| 86 | + | matches!( | |
| 87 | + | self, | |
| 88 | + | CoordinationOp::GetSession | CoordinationOp::ListSessions | CoordinationOp::ListClaims | CoordinationOp::GetWork | |
| 89 | + | ) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | pub fn description(self) -> &'static str { | |
| 93 | + | match self { | |
| 94 | + | CoordinationOp::StartSession => "Start a session: one run of your own harness (a Claude Code session, a Codex run) on a repository, so others can see who is working on what. `harness` is claude_code, codex, cursor, copilot or other. Give `harness_session_id`, the harness's own id for the session, and starting again with it returns the session already started (refreshing the issue, pull request, branch, title and head given) instead of a new one, so a hook that fires twice is harmless. `issue` and `pull` attach it to an issue and a pull request of the repository; a pull request for an issue attaches that issue too. Returns the session (`id` `ses_…`, `state` active, idle or ended). A new session, or one started again after it ended, is a session.started event. Needs a confirmed email address and a role that may open issues.", | |
| 95 | + | CoordinationOp::UpdateSession => "Update one of your sessions: attach it to an issue or pull request, or change its branch, head commit or title. Only the fields given change. An empty body is a heartbeat: every call keeps the session active and renews the claims made from it. A session reads as idle after 15 minutes without one and ends (`end_reason` timed_out) after 2 hours; a heartbeat brings a timed-out one back. Attaching a pull request moves the entries recorded so far into that pull request's session. Returns the session; one ended with end_session is refused with `409`.", | |
| 96 | + | CoordinationOp::EndSession => "End one of your sessions, with an optional `reason`. A session.ended event. Ending one already ended returns it as it is. Its claims are not released: release them, hand the issue off, or let them expire.", | |
| 97 | + | CoordinationOp::GetSession => "A session and its entries, oldest first, numbered within the session (`seq`); `after` gives the entries after that number, 500 at a time. Anyone who can read the repository may.", | |
| 98 | + | CoordinationOp::ListSessions => "A repository's sessions, most recently heard from first, up to 100: `state` active (heard from in the last 15 minutes), idle or ended, and `issue` or `pull` for those attached to one. Anyone who can read the repository may.", | |
| 99 | + | CoordinationOp::RecordSessionEntries => "Record entries in one of your sessions: up to 200 at a time, each with `kind` (prompt, message, tool_call, tool_result or note), `text` (up to 64,000 characters), and for tools `tool`. Give each a `key` and recording it again does nothing, so a retried hook records it once. Entries of a session attached to a pull request show in that pull request's session too, where reviewers read why the change is what it is. Recording is a heartbeat. Returns how many were `recorded` and how many were `duplicates`.", | |
| 100 | + | CoordinationOp::ClaimIssue => "Claim an open issue: say you are working on it. A claim is advisory, never a lock: anyone may claim an issue someone else has, and `others` lists the claims already on it, so you can coordinate or pick something else. It lasts `ttl_minutes` (30 unless you say, up to 240) and is renewed by claiming again or, when you give the `session_id` of one of your sessions, by that session's heartbeats. Returns your `claim`, the `others`, and `blocked_by`: each issue this one waits on, with its claims and pull requests. A new claim is a work.claimed event; one taking over a hand-off says which in `took_over`.", | |
| 101 | + | CoordinationOp::ReleaseClaim => "Release your claim on an issue, with an optional `reason`: you stopped working on it. A work.released event. Claims also end when the issue closes, and expire when not renewed. Refused with `404` when you hold no active claim on it.", | |
| 102 | + | CoordinationOp::HandOffIssue => "Hand an issue you claimed off: your claim ends as handed_off, with `note` saying where the work stands, for `to` (a username) or, without one, for anyone. A work.handoff_requested event, which whoever follows the issue hears; the next claim on it says it took the hand-off over. Refused with `404` when you hold no active claim on it.", | |
| 103 | + | CoordinationOp::ListClaims => "A repository's claims, newest first, up to 100: active ones unless `state` says released, expired or handed_off, and `issue` for one issue's. Anyone who can read the repository may.", | |
| 104 | + | CoordinationOp::GetWork => "Everything about an issue an agent needs before working on it, in one call: the `issue`, its `definition_of_done` (the items under its \"Definition of done\" heading) and the `required_checks` every pull request into the default branch must pass; active `claims`; `sessions` on it or its pull requests; its `pulls`, each with its status, required checks as they stand on its head, whether it merges cleanly, the other pull requests it overlaps and its place in the merge queue; `blocked_by` (each issue it waits on, with claims and pull requests) and `blocks` (the issues waiting on it); `decisions` recorded about it; `last_integration_failure`; and `recent`, its latest coordination events, newest first.", | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | pub fn input(self) -> Value { | |
| 109 | + | let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." }); | |
| 110 | + | let id = json!({ "type": "string", "description": "The session's id (ses_…), from start_session or list_sessions." }); | |
| 111 | + | let number = json!({ "type": "integer", "description": "The issue's number." }); | |
| 112 | + | let issue = json!({ "type": "integer", "description": "The issue it works on." }); | |
| 113 | + | let pull = json!({ "type": "integer", "description": "The pull request it works on." }); | |
| 114 | + | let fields = |more: Value| -> Value { | |
| 115 | + | let mut properties = json!({ | |
| 116 | + | "repo": repo, | |
| 117 | + | "issue": issue, | |
| 118 | + | "pull": pull, | |
| 119 | + | "branch": { "type": "string", "description": "The branch it works on." }, | |
| 120 | + | "title": { "type": "string", "description": "What it is doing, in a few words. Up to 200 characters." }, | |
| 121 | + | "head_commit": { "type": "string", "description": "The commit it is at, such as after a push." }, | |
| 122 | + | }); | |
| 123 | + | if let (Some(all), Value::Object(more)) = (properties.as_object_mut(), more) { | |
| 124 | + | all.extend(more); | |
| 125 | + | } | |
| 126 | + | properties | |
| 127 | + | }; | |
| 128 | + | let (properties, required): (Value, &[&str]) = match self { | |
| 129 | + | CoordinationOp::StartSession => ( | |
| 130 | + | fields(json!({ | |
| 131 | + | "harness": { | |
| 132 | + | "type": "string", | |
| 133 | + | "enum": Harness::ALL.map(Harness::as_str), | |
| 134 | + | "description": "The harness the session runs in.", | |
| 135 | + | }, | |
| 136 | + | "harness_session_id": { | |
| 137 | + | "type": "string", | |
| 138 | + | "description": "The harness's own id for the session, such as Claude Code's session_id. Starting again with it returns the same session.", | |
| 139 | + | }, | |
| 140 | + | })), | |
| 141 | + | &["repo", "harness"], | |
| 142 | + | ), | |
| 143 | + | CoordinationOp::UpdateSession => (fields(json!({ "id": id })), &["repo", "id"]), | |
| 144 | + | CoordinationOp::EndSession => ( | |
| 145 | + | json!({ | |
| 146 | + | "repo": repo, | |
| 147 | + | "id": id, | |
| 148 | + | "reason": { "type": "string", "description": "Why it ended, in a few words. \"ended\" if left out." }, | |
| 149 | + | }), | |
| 150 | + | &["repo", "id"], | |
| 151 | + | ), | |
| 152 | + | CoordinationOp::GetSession => ( | |
| 153 | + | json!({ | |
| 154 | + | "repo": repo, | |
| 155 | + | "id": id, | |
| 156 | + | "after": { "type": "integer", "description": "Only entries after this sequence number, for the next page." }, | |
| 157 | + | }), | |
| 158 | + | &["repo", "id"], | |
| 159 | + | ), | |
| 160 | + | CoordinationOp::ListSessions => ( | |
| 161 | + | json!({ | |
| 162 | + | "repo": repo, | |
| 163 | + | "state": { "type": "string", "enum": ["active", "idle", "ended"], "description": "Only sessions in this state." }, | |
| 164 | + | "issue": { "type": "integer", "description": "Only sessions on this issue." }, | |
| 165 | + | "pull": { "type": "integer", "description": "Only sessions on this pull request." }, | |
| 166 | + | }), | |
| 167 | + | &["repo"], | |
| 168 | + | ), | |
| 169 | + | CoordinationOp::RecordSessionEntries => ( | |
| 170 | + | json!({ | |
| 171 | + | "repo": repo, | |
| 172 | + | "id": id, | |
| 173 | + | "entries": { | |
| 174 | + | "type": "array", | |
| 175 | + | "description": "Up to 200 entries, oldest first.", | |
| 176 | + | "items": { | |
| 177 | + | "type": "object", | |
| 178 | + | "properties": { | |
| 179 | + | "kind": { "type": "string", "enum": ["prompt", "message", "tool_call", "tool_result", "note"] }, | |
| 180 | + | "text": { "type": "string" }, | |
| 181 | + | "tool": { "type": "string", "description": "For tool_call and tool_result: the tool's name." }, | |
| 182 | + | "commit": { "type": "string", "description": "The commit it was recorded at; the session's head if left out." }, | |
| 183 | + | "key": { "type": "string", "description": "An idempotency key, unique in the session: an entry with a key recorded before is skipped." }, | |
| 184 | + | }, | |
| 185 | + | "required": ["kind", "text"], | |
| 186 | + | }, | |
| 187 | + | }, | |
| 188 | + | }), | |
| 189 | + | &["repo", "id", "entries"], | |
| 190 | + | ), | |
| 191 | + | CoordinationOp::ClaimIssue => ( | |
| 192 | + | json!({ | |
| 193 | + | "repo": repo, | |
| 194 | + | "number": number, | |
| 195 | + | "session_id": { "type": "string", "description": "One of your sessions (ses_…) on the repository: its heartbeats renew the claim." }, | |
| 196 | + | "note": { "type": "string", "description": "What you are doing, for whoever else looks. Up to 500 characters." }, | |
| 197 | + | "ttl_minutes": { "type": "integer", "description": "How long it lasts unless renewed: 30 unless you say, at most 240." }, | |
| 198 | + | }), | |
| 199 | + | &["repo", "number"], | |
| 200 | + | ), | |
| 201 | + | CoordinationOp::ReleaseClaim => ( | |
| 202 | + | json!({ | |
| 203 | + | "repo": repo, | |
| 204 | + | "number": number, | |
| 205 | + | "reason": { "type": "string", "description": "Why, in a few words. \"released\" if left out." }, | |
| 206 | + | }), | |
| 207 | + | &["repo", "number"], | |
| 208 | + | ), | |
| 209 | + | CoordinationOp::HandOffIssue => ( | |
| 210 | + | json!({ | |
| 211 | + | "repo": repo, | |
| 212 | + | "number": number, | |
| 213 | + | "to": { "type": "string", "description": "The username it is for. Left out, it is for anyone." }, | |
| 214 | + | "note": { "type": "string", "description": "Where the work stands: what is done, what is left, what to watch for. Up to 500 characters." }, | |
| 215 | + | }), | |
| 216 | + | &["repo", "number", "note"], | |
| 217 | + | ), | |
| 218 | + | CoordinationOp::ListClaims => ( | |
| 219 | + | json!({ | |
| 220 | + | "repo": repo, | |
| 221 | + | "state": { | |
| 222 | + | "type": "string", | |
| 223 | + | "enum": ["active", "released", "expired", "handed_off"], | |
| 224 | + | "description": "Claims in this state; active if left out.", | |
| 225 | + | }, | |
| 226 | + | "issue": { "type": "integer", "description": "Only claims on this issue." }, | |
| 227 | + | }), | |
| 228 | + | &["repo"], | |
| 229 | + | ), | |
| 230 | + | CoordinationOp::GetWork => (json!({ "repo": repo, "number": number }), &["repo", "number"]), | |
| 231 | + | }; | |
| 232 | + | json!({ "type": "object", "properties": properties, "required": required }) | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | fn text(input: &Value, key: &str) -> Option<String> { | |
| 237 | + | input[key].as_str().map(str::trim).filter(|text| !text.is_empty()).map(str::to_owned) | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | /// A whole number given as a number, as digits, or as `#12`. | |
| 241 | + | fn number(input: &Value, key: &str) -> Option<u32> { | |
| 242 | + | match &input[key] { | |
| 243 | + | Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()), | |
| 244 | + | Value::String(digits) => digits.trim().trim_start_matches('#').parse().ok(), | |
| 245 | + | _ => None, | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | /// An optional whole number: none when left out, refused when not one. | |
| 250 | + | fn optional_number(input: &Value, key: &str) -> std::result::Result<Option<u32>, String> { | |
| 251 | + | match &input[key] { | |
| 252 | + | Value::Null => Ok(None), | |
| 253 | + | Value::String(text) if text.trim().is_empty() => Ok(None), | |
| 254 | + | _ => number(input, key).map(Some).ok_or_else(|| format!("{key} is a number.")), | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | /// `blocked_by` for `update_issue`: issue numbers, as numbers, digits or | |
| 259 | + | /// `#12`. None when left out; refused when it holds anything else. | |
| 260 | + | pub fn blocked_by(input: &Value) -> std::result::Result<Option<Vec<u32>>, String> { | |
| 261 | + | let items = match &input["blocked_by"] { | |
| 262 | + | Value::Null => return Ok(None), | |
| 263 | + | Value::Array(items) => items, | |
| 264 | + | _ => return Err("blocked_by is a list of issue numbers.".to_owned()), | |
| 265 | + | }; | |
| 266 | + | items | |
| 267 | + | .iter() | |
| 268 | + | .map(|item| number(&json!({ "n": item }), "n").ok_or_else(|| format!("blocked_by holds {item}, which is not an issue number."))) | |
| 269 | + | .collect::<std::result::Result<Vec<u32>, String>>() | |
| 270 | + | .map(Some) | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | pub async fn run(op: CoordinationOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 274 | + | let Some(repo) = repo_path(input) else { | |
| 275 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\".")); | |
| 276 | + | }; | |
| 277 | + | let invalid = |message: String| Ok(Outcome::fail(FailureCode::Invalid, message)); | |
| 278 | + | let (issue, pull) = match (optional_number(input, "issue"), optional_number(input, "pull")) { | |
| 279 | + | (Ok(issue), Ok(pull)) => (issue, pull), | |
| 280 | + | (Err(message), _) | (_, Err(message)) => return invalid(message), | |
| 281 | + | }; | |
| 282 | + | let actor = || viewer.clone().unwrap_or_default(); | |
| 283 | + | let id = || text(input, "id").unwrap_or_default(); | |
| 284 | + | let issue_number = number(input, "number").unwrap_or_default(); | |
| 285 | + | let work = &services.work; | |
| 286 | + | if matches!( | |
| 287 | + | op, | |
| 288 | + | CoordinationOp::UpdateSession | CoordinationOp::EndSession | CoordinationOp::GetSession | CoordinationOp::RecordSessionEntries | |
| 289 | + | ) && id().is_empty() | |
| 290 | + | { | |
| 291 | + | return invalid("Name the session by its id (ses_…).".to_owned()); | |
| 292 | + | } | |
| 293 | + | match op { | |
| 294 | + | CoordinationOp::StartSession => { | |
| 295 | + | let Some(harness) = input["harness"].as_str().and_then(Harness::parse) else { | |
| 296 | + | let names: Vec<&str> = Harness::ALL.iter().map(|harness| harness.as_str()).collect(); | |
| 297 | + | return invalid(format!("harness is one of {}.", names.join(", "))); | |
| 298 | + | }; | |
| 299 | + | let args = StartSessionArgs { | |
| 300 | + | actor: actor(), | |
| 301 | + | repo, | |
| 302 | + | harness, | |
| 303 | + | harness_session_id: text(input, "harness_session_id"), | |
| 304 | + | issue, | |
| 305 | + | pull, | |
| 306 | + | branch: text(input, "branch"), | |
| 307 | + | title: text(input, "title"), | |
| 308 | + | head_commit: text(input, "head_commit"), | |
| 309 | + | }; | |
| 310 | + | g1t_kit::call(work, "start_session", &args).await | |
| 311 | + | } | |
| 312 | + | CoordinationOp::UpdateSession => { | |
| 313 | + | let args = UpdateSessionArgs { | |
| 314 | + | actor: actor(), | |
| 315 | + | repo, | |
| 316 | + | id: id(), | |
| 317 | + | issue, | |
| 318 | + | pull, | |
| 319 | + | branch: text(input, "branch"), | |
| 320 | + | head_commit: text(input, "head_commit"), | |
| 321 | + | title: text(input, "title"), | |
| 322 | + | }; | |
| 323 | + | g1t_kit::call(work, "update_session", &args).await | |
| 324 | + | } | |
| 325 | + | CoordinationOp::EndSession => { | |
| 326 | + | let args = EndSessionArgs { actor: actor(), repo, id: id(), reason: text(input, "reason") }; | |
| 327 | + | g1t_kit::call(work, "end_session", &args).await | |
| 328 | + | } | |
| 329 | + | CoordinationOp::GetSession => { | |
| 330 | + | let args = WorkSessionArgs { viewer: viewer.clone(), repo, id: id(), after_seq: number(input, "after").unwrap_or_default() }; | |
| 331 | + | g1t_kit::call(work, "get_work_session", &args).await | |
| 332 | + | } | |
| 333 | + | CoordinationOp::ListSessions => { | |
| 334 | + | let state = match text(input, "state") { | |
| 335 | + | None => None, | |
| 336 | + | Some(state) => match SessionState::parse(&state) { | |
| 337 | + | Some(state) => Some(state), | |
| 338 | + | None => return invalid(format!("state is active, idle or ended, not {state}.")), | |
| 339 | + | }, | |
| 340 | + | }; | |
| 341 | + | let args = ListWorkSessionsArgs { viewer: viewer.clone(), repo, state, issue, pull }; | |
| 342 | + | g1t_kit::call(work, "list_work_sessions", &args).await | |
| 343 | + | } | |
| 344 | + | CoordinationOp::RecordSessionEntries => { | |
| 345 | + | let entries: Vec<KeyedSessionEntry> = match serde_json::from_value(input["entries"].clone()) { | |
| 346 | + | Ok(entries) => entries, | |
| 347 | + | Err(_) => { | |
| 348 | + | return invalid( | |
| 349 | + | "entries is a list of { kind, text }, kind one of prompt, message, tool_call, tool_result or note, with tool, commit and key as needed.".to_owned(), | |
| 350 | + | ); | |
| 351 | + | } | |
| 352 | + | }; | |
| 353 | + | let args = RecordEntriesArgs { actor: actor(), repo, id: id(), entries }; | |
| 354 | + | g1t_kit::call(work, "record_session_entries", &args).await | |
| 355 | + | } | |
| 356 | + | CoordinationOp::ClaimIssue => { | |
| 357 | + | let ttl_minutes = match optional_number(input, "ttl_minutes") { | |
| 358 | + | Ok(minutes) => minutes, | |
| 359 | + | Err(message) => return invalid(message), | |
| 360 | + | }; | |
| 361 | + | let args = ClaimArgs { | |
| 362 | + | actor: actor(), | |
| 363 | + | repo, | |
| 364 | + | number: issue_number, | |
| 365 | + | session_id: text(input, "session_id"), | |
| 366 | + | note: text(input, "note"), | |
| 367 | + | ttl_minutes, | |
| 368 | + | }; | |
| 369 | + | g1t_kit::call(work, "claim_issue", &args).await | |
| 370 | + | } | |
| 371 | + | CoordinationOp::ReleaseClaim => { | |
| 372 | + | let args = ReleaseArgs { actor: actor(), repo, number: issue_number, reason: text(input, "reason") }; | |
| 373 | + | g1t_kit::call(work, "release_claim", &args).await | |
| 374 | + | } | |
| 375 | + | CoordinationOp::HandOffIssue => { | |
| 376 | + | let Some(note) = text(input, "note") else { | |
| 377 | + | return invalid("Say where the work stands in note, for whoever picks it up.".to_owned()); | |
| 378 | + | }; | |
| 379 | + | let args = HandOffArgs { actor: actor(), repo, number: issue_number, to: text(input, "to"), note }; | |
| 380 | + | g1t_kit::call(work, "hand_off_issue", &args).await | |
| 381 | + | } | |
| 382 | + | CoordinationOp::ListClaims => { | |
| 383 | + | let state = match text(input, "state") { | |
| 384 | + | None => None, | |
| 385 | + | Some(state) => match ClaimState::parse(&state) { | |
| 386 | + | Some(state) => Some(state), | |
| 387 | + | None => return invalid(format!("state is active, released, expired or handed_off, not {state}.")), | |
| 388 | + | }, | |
| 389 | + | }; | |
| 390 | + | let args = ListClaimsArgs { viewer: viewer.clone(), repo, state, issue }; | |
| 391 | + | g1t_kit::call(work, "list_claims", &args).await | |
| 392 | + | } | |
| 393 | + | CoordinationOp::GetWork => { | |
| 394 | + | let args = GetWorkArgs { viewer: viewer.clone(), repo, number: issue_number }; | |
| 395 | + | g1t_kit::call(work, "get_work", &args).await | |
| 396 | + | } | |
| 397 | + | } | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | #[cfg(test)] | |
| 401 | + | mod tests { | |
| 402 | + | use super::*; | |
| 403 | + | use crate::operations::Op; | |
| 404 | + | use g1t_contracts::scopes::{Level, Scope, scope_for}; | |
| 405 | + | ||
| 406 | + | #[test] | |
| 407 | + | fn each_operation_is_described_scoped_and_listed() { | |
| 408 | + | for op in CoordinationOp::ALL { | |
| 409 | + | assert!(Op::ALL.contains(&Op::Coordination(op)), "{}", op.name()); | |
| 410 | + | assert_eq!(Op::by_name(op.name()), Some(Op::Coordination(op))); | |
| 411 | + | assert!(!op.title().is_empty() && op.description().len() > 80, "{}", op.name()); | |
| 412 | + | assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name()); | |
| 413 | + | let scope = scope_for(op.name()).unwrap(); | |
| 414 | + | assert_eq!(scope.level() == Level::Read, op.reads(), "{}", op.name()); | |
| 415 | + | let sessions = op.name().contains("session"); | |
| 416 | + | let expected = match (sessions, op.reads()) { | |
| 417 | + | (true, true) => Scope::PullRequestsRead, | |
| 418 | + | (true, false) => Scope::PullRequestsWrite, | |
| 419 | + | (false, true) => Scope::IssuesRead, | |
| 420 | + | (false, false) => Scope::IssuesWrite, | |
| 421 | + | }; | |
| 422 | + | assert_eq!(scope, expected, "{}", op.name()); | |
| 423 | + | } | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | #[test] | |
| 427 | + | fn routes_reach_each_operation_with_its_input() { | |
| 428 | + | let resolve = |method: &str, path: &str, query: &[(&str, &str)], body: Value| { | |
| 429 | + | let query: Vec<(String, String)> = query.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect(); | |
| 430 | + | let (route, input) = crate::rest::resolve(method, path, &query, body).unwrap(); | |
| 431 | + | (route.op, input) | |
| 432 | + | }; | |
| 433 | + | let coordination = |op| Op::Coordination(op); | |
| 434 | + | let (op, input) = resolve("POST", "/repos/acme/web/sessions", &[], json!({ "harness": "claude_code" })); | |
| 435 | + | assert_eq!(op, coordination(CoordinationOp::StartSession)); | |
| 436 | + | assert_eq!(input, json!({ "harness": "claude_code", "repo": "acme/web" })); | |
| 437 | + | let (op, input) = resolve("PATCH", "/repos/acme/web/sessions/ses_1", &[], json!({})); | |
| 438 | + | assert_eq!(op, coordination(CoordinationOp::UpdateSession)); | |
| 439 | + | assert_eq!(input, json!({ "id": "ses_1", "repo": "acme/web" })); | |
| 440 | + | let (op, _) = resolve("POST", "/repos/acme/web/sessions/ses_1/end", &[], json!({})); | |
| 441 | + | assert_eq!(op, coordination(CoordinationOp::EndSession)); | |
| 442 | + | let (op, input) = resolve("GET", "/repos/acme/web/sessions/ses_1", &[("after", "40")], Value::Null); | |
| 443 | + | assert_eq!(op, coordination(CoordinationOp::GetSession)); | |
| 444 | + | assert_eq!(input["after"], "40"); | |
| 445 | + | let (op, input) = resolve("GET", "/repos/acme/web/sessions", &[("state", "idle"), ("issue", "4")], Value::Null); | |
| 446 | + | assert_eq!(op, coordination(CoordinationOp::ListSessions)); | |
| 447 | + | assert_eq!(input, json!({ "state": "idle", "issue": "4", "repo": "acme/web" })); | |
| 448 | + | let (op, _) = resolve("POST", "/repos/acme/web/sessions/ses_1/entries", &[], json!({ "entries": [] })); | |
| 449 | + | assert_eq!(op, coordination(CoordinationOp::RecordSessionEntries)); | |
| 450 | + | let (op, input) = resolve("POST", "/repos/acme/web/issues/4/claim", &[], json!({ "note": "on it" })); | |
| 451 | + | assert_eq!(op, coordination(CoordinationOp::ClaimIssue)); | |
| 452 | + | assert_eq!(input, json!({ "note": "on it", "number": 4, "repo": "acme/web" })); | |
| 453 | + | let (op, _) = resolve("POST", "/repos/acme/web/issues/4/release", &[], json!({})); | |
| 454 | + | assert_eq!(op, coordination(CoordinationOp::ReleaseClaim)); | |
| 455 | + | let (op, _) = resolve("POST", "/repos/acme/web/issues/4/handoff", &[], json!({ "note": "half done" })); | |
| 456 | + | assert_eq!(op, coordination(CoordinationOp::HandOffIssue)); | |
| 457 | + | let (op, _) = resolve("GET", "/repos/acme/web/claims", &[("state", "active")], Value::Null); | |
| 458 | + | assert_eq!(op, coordination(CoordinationOp::ListClaims)); | |
| 459 | + | let (op, input) = resolve("GET", "/repos/acme/web/issues/4/work", &[], Value::Null); | |
| 460 | + | assert_eq!(op, coordination(CoordinationOp::GetWork)); | |
| 461 | + | assert_eq!(input, json!({ "number": 4, "repo": "acme/web" })); | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | #[test] | |
| 465 | + | fn mcp_actions_reach_each_operation() { | |
| 466 | + | use crate::tools::{Tool, resolve}; | |
| 467 | + | let call = |tool: &str, action: &str| resolve(Tool::by_name(tool).unwrap(), &json!({ "action": action, "repo": "a/b", "id": "ses_1", "number": 4, "harness": "codex", "entries": [], "note": "x" })); | |
| 468 | + | assert_eq!(call("session", "start"), Ok(Op::Coordination(CoordinationOp::StartSession))); | |
| 469 | + | assert_eq!(call("session", "update"), Ok(Op::Coordination(CoordinationOp::UpdateSession))); | |
| 470 | + | assert_eq!(call("session", "record"), Ok(Op::Coordination(CoordinationOp::RecordSessionEntries))); | |
| 471 | + | assert_eq!(call("issue", "claim"), Ok(Op::Coordination(CoordinationOp::ClaimIssue))); | |
| 472 | + | assert_eq!(call("issue", "work"), Ok(Op::Coordination(CoordinationOp::GetWork))); | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | #[test] | |
| 476 | + | fn numbers_come_as_numbers_digits_or_with_a_hash() { | |
| 477 | + | assert_eq!(blocked_by(&json!({})), Ok(None)); | |
| 478 | + | assert_eq!(blocked_by(&json!({ "blocked_by": [3, "4", "#5"] })), Ok(Some(vec![3, 4, 5]))); | |
| 479 | + | assert_eq!(blocked_by(&json!({ "blocked_by": [] })), Ok(Some(vec![]))); | |
| 480 | + | assert!(blocked_by(&json!({ "blocked_by": ["x"] })).is_err()); | |
| 481 | + | assert!(blocked_by(&json!({ "blocked_by": 3 })).is_err()); | |
| 482 | + | assert_eq!(optional_number(&json!({ "issue": "" }), "issue"), Ok(None)); | |
| 483 | + | assert!(optional_number(&json!({ "issue": "soon" }), "issue").is_err()); | |
| 484 | + | } | |
| 485 | + | } |
| 14 | 14 | mod blobs; | |
| 15 | 15 | mod broker; | |
| 16 | 16 | mod checks; | |
| 17 | + | mod coordination; | |
| 17 | 18 | mod deployments; | |
| 18 | 19 | mod telemetry; | |
| 19 | 20 | mod deploy_keys; |
| 11 | 11 | const SUPPORTED_VERSIONS: [&str; 3] = ["2025-06-18", "2025-03-26", "2024-11-05"]; | |
| 12 | 12 | ||
| 13 | 13 | const INSTRUCTIONS: &str = "g1t is where your code, its history and the work around it live: repositories, issues, pull requests, workflows, deployments and packages. You bring your own agent; these tools are how it reads and changes what is on g1t, as the person or token it acts for. Repositories are named \"owner/name\"; issues and pull requests in one share a sequence of numbers. | |
| 14 | − | Tools are resources, each with an `action`: search, repository, issue, pull_request, memory, workflow, observability, package, secret, webhook, access, team, workspace, billing, security, notifications, account, artifact. The `action` field lists each action and the fields it needs. You see only what your token's scopes allow; a refusal names the scope it needs. | |
| 14 | + | Tools are resources, each with an `action`: search, repository, issue, pull_request, session, memory, workflow, observability, package, secret, webhook, access, team, workspace, billing, security, notifications, account, artifact. The `action` field lists each action and the fields it needs. You see only what your token's scopes allow; a refusal names the scope it needs. | |
| 15 | 15 | Find a repository: account whoami lists your workspaces; repository list or search finds one. | |
| 16 | − | Work on an issue: issue get (read it and the pull requests already made for it), memory recall, then pull_request create with the issue's number: you get a draft with its own fork to clone and push to. Push your commits, then pull_request ready with a summary. Watch `overlaps` and `behind` on pull_request get, and its checks there: `statuses` from the repository's workflows and `required_checks`, which must pass before it merges. If one fails, read why with workflow get_run and job_logs, push a fix, and the checks run again. | |
| 16 | + | Work on an issue: issue work (everything about it: what done means, who is on it, its pull requests, what it waits on), issue claim (advisory: it tells you who else is on it), memory recall, then pull_request create with the issue's number: you get a draft with its own fork to clone and push to. Push your commits, then pull_request ready with a summary. Watch `overlaps` and `behind` on pull_request get, and its checks there: `statuses` from the repository's workflows and `required_checks`, which must pass before it merges. If one fails, read why with workflow get_run and job_logs, push a fix, and the checks run again. | |
| 17 | + | Coordinate: session start once per run of your harness (harness_session_id makes it idempotent), session update as a heartbeat, session record your prompts and tool calls, session end when done; issue release or handoff (with a note) when you stop. | |
| 17 | 18 | Record your session: as you work, pull_request record_session appends your prompts, reasoning and the tools you ran to the pull request's session, so reviewers see why the change is what it is; pull_request read_session reads it back. pull_request record_decision keeps a decision (why the change is made this way, or what fixed a failure) and list_decisions reads them; repository why says why a line of code is the way it is. | |
| 18 | 19 | Merge queue: when your pull request fails combined with the ones ahead of it, g1t says which of them it breaks with and the check that failed (on the pull request, its issue and its session, and as integration.failed). Fix it and push: once its checks pass, it rejoins the queue by itself. | |
| 19 | 20 | Memory: memory recall before you start to learn how the project builds and what it has decided; when you learn something the next agent needs, memory remember it, one short fact at a time (scope project or workspace). Never a secret. |
| 15 | 15 | use crate::telemetry::TelemetryOp; | |
| 16 | 16 | use crate::packages::PackagesOp; | |
| 17 | 17 | use crate::folios::FoliosOp; | |
| 18 | + | use crate::coordination::CoordinationOp; | |
| 18 | 19 | use crate::protection::ProtectionOp; | |
| 19 | 20 | use crate::token_policy::TokenOp; | |
| 20 | 21 | use crate::operations::Op; | |
| ⋯ | |||
| 336 | 337 | ], | |
| 337 | 338 | ), | |
| 338 | 339 | ( | |
| 340 | + | "Claims and work", | |
| 341 | + | "Coordinating work on issues: an advisory, expiring claim that says you are on one (never a lock), handing it off with a note, and everything an agent needs before starting on an issue in one call. What an issue is blocked by is set with update_issue.", | |
| 342 | + | &[ | |
| 343 | + | Op::Coordination(CoordinationOp::GetWork), | |
| 344 | + | Op::Coordination(CoordinationOp::ClaimIssue), | |
| 345 | + | Op::Coordination(CoordinationOp::ReleaseClaim), | |
| 346 | + | Op::Coordination(CoordinationOp::HandOffIssue), | |
| 347 | + | Op::Coordination(CoordinationOp::ListClaims), | |
| 348 | + | ], | |
| 349 | + | ), | |
| 350 | + | ( | |
| 339 | 351 | "Labels and milestones", | |
| 340 | 352 | "A repository's labels, which issues and pull requests carry by name, and its milestones, which gather them under a goal and a due date.", | |
| 341 | 353 | &[ | |
| ⋯ | |||
| 373 | 385 | ), | |
| 374 | 386 | ( | |
| 375 | 387 | "Sessions", | |
| 376 | − | "The record of how a pull request was made: prompts, reasoning and the tools that ran.", | |
| 377 | − | &[Op::ReadSession, Op::RecordSession], | |
| 388 | + | "Who is working on what, and how a pull request was made. A session is one run of your own harness (a Claude Code session, a Codex run) on a repository: started once, kept alive by heartbeats, attached to an issue and a pull request, and recording prompts, reasoning and the tools that ran. A pull request's session shows what was recorded on it.", | |
| 389 | + | &[ | |
| 390 | + | Op::Coordination(CoordinationOp::StartSession), | |
| 391 | + | Op::Coordination(CoordinationOp::UpdateSession), | |
| 392 | + | Op::Coordination(CoordinationOp::EndSession), | |
| 393 | + | Op::Coordination(CoordinationOp::GetSession), | |
| 394 | + | Op::Coordination(CoordinationOp::ListSessions), | |
| 395 | + | Op::Coordination(CoordinationOp::RecordSessionEntries), | |
| 396 | + | Op::ReadSession, | |
| 397 | + | Op::RecordSession, | |
| 398 | + | ], | |
| 378 | 399 | ), | |
| 379 | 400 | ( | |
| 380 | 401 | "Decisions and why", | |
| ⋯ | |||
| 780 | 801 | Op::DeployKeys(op) => op.title(), | |
| 781 | 802 | Op::Packages(op) => op.title(), | |
| 782 | 803 | Op::Folios(op) => op.title(), | |
| 804 | + | Op::Coordination(op) => op.title(), | |
| 783 | 805 | } | |
| 784 | 806 | } | |
| 785 | 807 | ||
| 35 | 35 | use crate::telemetry::TelemetryOp; | |
| 36 | 36 | use crate::packages::PackagesOp; | |
| 37 | 37 | use crate::folios::FoliosOp; | |
| 38 | + | use crate::coordination::CoordinationOp; | |
| 38 | 39 | use crate::protection::ProtectionOp; | |
| 39 | 40 | use crate::token_policy::TokenOp; | |
| 40 | 41 | use crate::rules::RulesOp; | |
| ⋯ | |||
| 320 | 321 | /// Artifacts mode's docs, slides, designs and dashboards, kept by the | |
| 321 | 322 | /// artifacts service: folios.rs. | |
| 322 | 323 | Folios(FoliosOp), | |
| 324 | + | /// Coordinating work: harness sessions, claims on issues and an | |
| 325 | + | /// issue's whole context, kept by the work service: coordination.rs. | |
| 326 | + | Coordination(CoordinationOp), | |
| 323 | 327 | } | |
| 324 | 328 | ||
| 325 | 329 | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { | |
| ⋯ | |||
| 689 | 693 | } | |
| 690 | 694 | ||
| 691 | 695 | impl Op { | |
| 692 | − | pub const ALL: [Op; 356] = [ | |
| 696 | + | pub const ALL: [Op; 367] = [ | |
| 693 | 697 | Op::Whoami, | |
| 694 | 698 | Op::GetWorkspace, | |
| 695 | 699 | Op::CreateWorkspace, | |
| ⋯ | |||
| 1046 | 1050 | Op::Folios(FoliosOp::RestoreVersion), | |
| 1047 | 1051 | Op::Folios(FoliosOp::SetAccess), | |
| 1048 | 1052 | Op::Folios(FoliosOp::Purge), | |
| 1053 | + | Op::Coordination(CoordinationOp::StartSession), | |
| 1054 | + | Op::Coordination(CoordinationOp::UpdateSession), | |
| 1055 | + | Op::Coordination(CoordinationOp::EndSession), | |
| 1056 | + | Op::Coordination(CoordinationOp::GetSession), | |
| 1057 | + | Op::Coordination(CoordinationOp::ListSessions), | |
| 1058 | + | Op::Coordination(CoordinationOp::RecordSessionEntries), | |
| 1059 | + | Op::Coordination(CoordinationOp::ClaimIssue), | |
| 1060 | + | Op::Coordination(CoordinationOp::ReleaseClaim), | |
| 1061 | + | Op::Coordination(CoordinationOp::HandOffIssue), | |
| 1062 | + | Op::Coordination(CoordinationOp::ListClaims), | |
| 1063 | + | Op::Coordination(CoordinationOp::GetWork), | |
| 1049 | 1064 | ]; | |
| 1050 | 1065 | ||
| 1051 | 1066 | pub fn by_name(name: &str) -> Option<Op> { | |
| ⋯ | |||
| 1247 | 1262 | Op::DeployKeys(op) => op.name(), | |
| 1248 | 1263 | Op::Packages(op) => op.name(), | |
| 1249 | 1264 | Op::Folios(op) => op.name(), | |
| 1265 | + | Op::Coordination(op) => op.name(), | |
| 1250 | 1266 | } | |
| 1251 | 1267 | } | |
| 1252 | 1268 | ||
| ⋯ | |||
| 1388 | 1404 | "Open an issue on a repository. Say what done means in the body if it helps, for instance under a \"Definition of done\" heading; what must pass before a pull request for it merges is the default branch's required checks, the same for every pull request. labels are the repository's labels by name; a name it does not have yet is created when you have the Triage role or higher, and refused otherwise. milestone, a milestone's number, needs the Triage role." | |
| 1389 | 1405 | } | |
| 1390 | 1406 | Op::UpdateIssue => { | |
| 1391 | − | "Change an issue's title, body, labels, milestone or the people it is assigned to. Only the fields given are changed; labels and assignees each replace the whole set, and milestone null or 0 takes it out of its milestone. Its author may change their own issue, as may the person g1t filed one for; anyone else needs the Triage role or higher, and so does the milestone. Each label added or removed is an issue.labeled or issue.unlabeled event." | |
| 1407 | + | "Change an issue's title, body, labels, milestone, the people it is assigned to, or what it is blocked by. Only the fields given are changed; labels, assignees and blocked_by each replace the whole set, and milestone null or 0 takes it out of its milestone. blocked_by lists the numbers of the issues of the same repository that have to land first: one that is not an issue there, the issue itself, or a set that would make a cycle is refused with `422`, and a change is a dependency.changed event (as is a blocker closing, or its pull request merging, later). Its author may change their own issue, as may the person g1t filed one for; anyone else needs the Triage role or higher, and so does the milestone. Each label added or removed is an issue.labeled or issue.unlabeled event." | |
| 1392 | 1408 | } | |
| 1393 | 1409 | Op::CloseIssue => { | |
| 1394 | 1410 | "Close an issue without a pull request. Merging a pull request made for an issue closes it for you. Its author may close their own issue, as may the person g1t filed one for; anyone else needs the Triage role or higher." | |
| ⋯ | |||
| 1766 | 1782 | Op::DeployKeys(op) => op.description(), | |
| 1767 | 1783 | Op::Packages(op) => op.description(), | |
| 1768 | 1784 | Op::Folios(op) => op.description(), | |
| 1785 | + | Op::Coordination(op) => op.description(), | |
| 1769 | 1786 | } | |
| 1770 | 1787 | } | |
| 1771 | 1788 | ||
| ⋯ | |||
| 2281 | 2298 | "items": { "type": "string" }, | |
| 2282 | 2299 | "description": "Usernames of the people it is assigned to. Replaces the whole set; an empty list unassigns everyone.", | |
| 2283 | 2300 | }, | |
| 2301 | + | "blocked_by": { | |
| 2302 | + | "type": "array", | |
| 2303 | + | "items": { "type": "integer" }, | |
| 2304 | + | "description": "The numbers of the issues of this repository that have to land first. Replaces the whole set; an empty list clears it. No cycles, and never the issue itself.", | |
| 2305 | + | }, | |
| 2284 | 2306 | })), | |
| 2285 | 2307 | &["repo", "number"], | |
| 2286 | 2308 | ), | |
| ⋯ | |||
| 3137 | 3159 | Op::DeployKeys(op) => op.input(), | |
| 3138 | 3160 | Op::Packages(op) => op.input(), | |
| 3139 | 3161 | Op::Folios(op) => op.input(), | |
| 3162 | + | Op::Coordination(op) => op.input(), | |
| 3140 | 3163 | } | |
| 3141 | 3164 | } | |
| 3142 | 3165 | ||
| ⋯ | |||
| 3176 | 3199 | if let Op::Packages(op) = self { | |
| 3177 | 3200 | return !op.anonymous(); | |
| 3178 | 3201 | } | |
| 3202 | + | // And who is working on what in a public repository. | |
| 3203 | + | if let Op::Coordination(op) = self { | |
| 3204 | + | return !op.reads(); | |
| 3205 | + | } | |
| 3179 | 3206 | !matches!( | |
| 3180 | 3207 | self, | |
| 3181 | 3208 | Op::ListRepos | |
| ⋯ | |||
| 4215 | 4242 | Ok(with_deprecation(opened, !checks.is_empty())) | |
| 4216 | 4243 | } | |
| 4217 | 4244 | Op::UpdateIssue => { | |
| 4245 | + | let blocked_by = match crate::coordination::blocked_by(input) { | |
| 4246 | + | Ok(blocked_by) => blocked_by, | |
| 4247 | + | Err(message) => return failed(FailureCode::Invalid, &message), | |
| 4248 | + | }; | |
| 4218 | 4249 | pass( | |
| 4219 | 4250 | work, | |
| 4220 | 4251 | "update_issue", | |
| ⋯ | |||
| 4227 | 4258 | labels: strings(input, "labels"), | |
| 4228 | 4259 | assignees: strings(input, "assignees"), | |
| 4229 | 4260 | milestone: milestone_input(input), | |
| 4261 | + | blocked_by, | |
| 4230 | 4262 | }, | |
| 4231 | 4263 | ) | |
| 4232 | 4264 | .await | |
| ⋯ | |||
| 5358 | 5390 | Op::DeployKeys(op) => crate::deploy_keys::run(op, services, viewer, input).await, | |
| 5359 | 5391 | Op::Packages(op) => crate::packages::run(op, services, viewer, input).await, | |
| 5360 | 5392 | Op::Folios(op) => crate::folios::run(op, services, viewer, input).await, | |
| 5393 | + | Op::Coordination(op) => crate::coordination::run(op, services, viewer, input).await, | |
| 5361 | 5394 | Op::ReopenSecurityAlert => { | |
| 5362 | 5395 | let changed: Outcome<AlertChange> = call( | |
| 5363 | 5396 | &services.security, | |
| 1186 | 1186 | "assignees": [ | |
| 1187 | 1187 | "syntaqx" | |
| 1188 | 1188 | ], | |
| 1189 | − | "milestone": 3 | |
| 1189 | + | "milestone": 3, | |
| 1190 | + | "blocked_by": [9] | |
| 1190 | 1191 | }, | |
| 1191 | 1192 | "response": { | |
| 1192 | 1193 | "id": "iss_01m43shrzpfe49x74ga7sj1c6v", | |
| ⋯ | |||
| 1218 | 1219 | "assignees": [ | |
| 1219 | 1220 | "syntaqx" | |
| 1220 | 1221 | ], | |
| 1221 | − | "blocked_by": [], | |
| 1222 | + | "blocked_by": [9], | |
| 1222 | 1223 | "queued": false, | |
| 1223 | 1224 | "agent": null, | |
| 1224 | 1225 | "milestone": { | |
| ⋯ | |||
| 2227 | 2228 | }, | |
| 2228 | 2229 | "notes": "Up to 200 entries can be appended per request. Each is recorded against the pull request's head commit at the time. See [sessions and why-blame](/guides/why-blame/)." | |
| 2229 | 2230 | }, | |
| 2231 | + | "start_session": { | |
| 2232 | + | "request": { | |
| 2233 | + | "harness": "claude_code", | |
| 2234 | + | "harness_session_id": "5b0c1f9e-3d2a-4e8b-9f61-0c7d2a9e4b13", | |
| 2235 | + | "issue": 12, | |
| 2236 | + | "branch": "greet-caller", | |
| 2237 | + | "title": "Make the greeting name the caller" | |
| 2238 | + | }, | |
| 2239 | + | "response": { | |
| 2240 | + | "id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2", | |
| 2241 | + | "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 2242 | + | "issue": 12, | |
| 2243 | + | "pull": null, | |
| 2244 | + | "branch": "greet-caller", | |
| 2245 | + | "actor": { | |
| 2246 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 2247 | + | "username": "syntaqx" | |
| 2248 | + | }, | |
| 2249 | + | "harness": "claude_code", | |
| 2250 | + | "harness_session_id": "5b0c1f9e-3d2a-4e8b-9f61-0c7d2a9e4b13", | |
| 2251 | + | "title": "Make the greeting name the caller", | |
| 2252 | + | "state": "active", | |
| 2253 | + | "head_commit": null, | |
| 2254 | + | "started_at": "2026-10-12T14:02:11.204Z", | |
| 2255 | + | "last_seen_at": "2026-10-12T14:02:11.204Z", | |
| 2256 | + | "ended_at": null, | |
| 2257 | + | "end_reason": null | |
| 2258 | + | }, | |
| 2259 | + | "notes": "Idempotent on `harness_session_id`: a second start with it returns this session, so call it from a hook without checking first. `state` is `active`, `idle` (no heartbeat for 15 minutes) or `ended` (ended, or no heartbeat for 2 hours: `end_reason` `timed_out`). See [Sessions](/guides/sessions/)." | |
| 2260 | + | }, | |
| 2261 | + | "update_session": { | |
| 2262 | + | "request": { | |
| 2263 | + | "pull": 14, | |
| 2264 | + | "head_commit": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13" | |
| 2265 | + | }, | |
| 2266 | + | "response": { | |
| 2267 | + | "id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2", | |
| 2268 | + | "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 2269 | + | "issue": 12, | |
| 2270 | + | "pull": 14, | |
| 2271 | + | "branch": "greet-caller", | |
| 2272 | + | "actor": { | |
| 2273 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 2274 | + | "username": "syntaqx" | |
| 2275 | + | }, | |
| 2276 | + | "harness": "claude_code", | |
| 2277 | + | "harness_session_id": "5b0c1f9e-3d2a-4e8b-9f61-0c7d2a9e4b13", | |
| 2278 | + | "title": "Make the greeting name the caller", | |
| 2279 | + | "state": "active", | |
| 2280 | + | "head_commit": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13", | |
| 2281 | + | "started_at": "2026-10-12T14:02:11.204Z", | |
| 2282 | + | "last_seen_at": "2026-10-12T14:31:40.518Z", | |
| 2283 | + | "ended_at": null, | |
| 2284 | + | "end_reason": null | |
| 2285 | + | }, | |
| 2286 | + | "notes": "Send `{}` as a heartbeat. Each call renews the session's claims and brings back one that timed out. Refused with `404` for a session that is not yours, and `409` for one you ended." | |
| 2287 | + | }, | |
| 2288 | + | "end_session": { | |
| 2289 | + | "request": { | |
| 2290 | + | "reason": "done" | |
| 2291 | + | }, | |
| 2292 | + | "response": { | |
| 2293 | + | "id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2", | |
| 2294 | + | "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 2295 | + | "issue": 12, | |
| 2296 | + | "pull": 14, | |
| 2297 | + | "branch": "greet-caller", | |
| 2298 | + | "actor": { | |
| 2299 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 2300 | + | "username": "syntaqx" | |
| 2301 | + | }, | |
| 2302 | + | "harness": "claude_code", | |
| 2303 | + | "harness_session_id": "5b0c1f9e-3d2a-4e8b-9f61-0c7d2a9e4b13", | |
| 2304 | + | "title": "Make the greeting name the caller", | |
| 2305 | + | "state": "ended", | |
| 2306 | + | "head_commit": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13", | |
| 2307 | + | "started_at": "2026-10-12T14:02:11.204Z", | |
| 2308 | + | "last_seen_at": "2026-10-12T14:40:03.115Z", | |
| 2309 | + | "ended_at": "2026-10-12T14:40:03.115Z", | |
| 2310 | + | "end_reason": "done" | |
| 2311 | + | } | |
| 2312 | + | }, | |
| 2313 | + | "get_session": { | |
| 2314 | + | "query": { | |
| 2315 | + | "after": 0 | |
| 2316 | + | }, | |
| 2317 | + | "response": { | |
| 2318 | + | "session": { | |
| 2319 | + | "id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2", | |
| 2320 | + | "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 2321 | + | "issue": 12, | |
| 2322 | + | "pull": 14, | |
| 2323 | + | "branch": "greet-caller", | |
| 2324 | + | "actor": { | |
| 2325 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 2326 | + | "username": "syntaqx" | |
| 2327 | + | }, | |
| 2328 | + | "harness": "claude_code", | |
| 2329 | + | "harness_session_id": "5b0c1f9e-3d2a-4e8b-9f61-0c7d2a9e4b13", | |
| 2330 | + | "title": "Make the greeting name the caller", | |
| 2331 | + | "state": "active", | |
| 2332 | + | "head_commit": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13", | |
| 2333 | + | "started_at": "2026-10-12T14:02:11.204Z", | |
| 2334 | + | "last_seen_at": "2026-10-12T14:31:40.518Z", | |
| 2335 | + | "ended_at": null, | |
| 2336 | + | "end_reason": null | |
| 2337 | + | }, | |
| 2338 | + | "entries": [ | |
| 2339 | + | { | |
| 2340 | + | "seq": 1, | |
| 2341 | + | "kind": "prompt", | |
| 2342 | + | "text": "Make the greeting name the caller (issue #12).", | |
| 2343 | + | "tool": null, | |
| 2344 | + | "commit": null, | |
| 2345 | + | "at": "2026-10-12T14:02:12.008Z" | |
| 2346 | + | }, | |
| 2347 | + | { | |
| 2348 | + | "seq": 2, | |
| 2349 | + | "kind": "tool_call", | |
| 2350 | + | "text": "cargo test", | |
| 2351 | + | "tool": "Bash", | |
| 2352 | + | "commit": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13", | |
| 2353 | + | "at": "2026-10-12T14:30:12.660Z" | |
| 2354 | + | } | |
| 2355 | + | ] | |
| 2356 | + | }, | |
| 2357 | + | "notes": "Entries are numbered within the session and come 500 at a time; send the last `seq` you have as `after` for the next page." | |
| 2358 | + | }, | |
| 2359 | + | "list_sessions": { | |
| 2360 | + | "query": { | |
| 2361 | + | "state": "active", | |
| 2362 | + | "issue": 12 | |
| 2363 | + | }, | |
| 2364 | + | "response": [ | |
| 2365 | + | { | |
| 2366 | + | "id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2", | |
| 2367 | + | "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 2368 | + | "issue": 12, | |
| 2369 | + | "pull": 14, | |
| 2370 | + | "branch": "greet-caller", | |
| 2371 | + | "actor": { | |
| 2372 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 2373 | + | "username": "syntaqx" | |
| 2374 | + | }, | |
| 2375 | + | "harness": "claude_code", | |
| 2376 | + | "harness_session_id": "5b0c1f9e-3d2a-4e8b-9f61-0c7d2a9e4b13", | |
| 2377 | + | "title": "Make the greeting name the caller", | |
| 2378 | + | "state": "active", | |
| 2379 | + | "head_commit": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13", | |
| 2380 | + | "started_at": "2026-10-12T14:02:11.204Z", | |
| 2381 | + | "last_seen_at": "2026-10-12T14:31:40.518Z", | |
| 2382 | + | "ended_at": null, | |
| 2383 | + | "end_reason": null | |
| 2384 | + | } | |
| 2385 | + | ] | |
| 2386 | + | }, | |
| 2387 | + | "record_session_entries": { | |
| 2388 | + | "request": { | |
| 2389 | + | "entries": [ | |
| 2390 | + | { | |
| 2391 | + | "kind": "prompt", | |
| 2392 | + | "text": "Make the greeting name the caller (issue #12).", | |
| 2393 | + | "key": "prompt-1" | |
| 2394 | + | }, | |
| 2395 | + | { | |
| 2396 | + | "kind": "tool_call", | |
| 2397 | + | "text": "cargo test", | |
| 2398 | + | "tool": "Bash", | |
| 2399 | + | "key": "tool-7" | |
| 2400 | + | } | |
| 2401 | + | ] | |
| 2402 | + | }, | |
| 2403 | + | "response": { | |
| 2404 | + | "recorded": 2, | |
| 2405 | + | "duplicates": 0 | |
| 2406 | + | }, | |
| 2407 | + | "notes": "Up to 200 entries a request. An entry whose `key` the session recorded before is counted in `duplicates` and not recorded again. Entries of a session on a pull request show in that pull request's session too (read_session)." | |
| 2408 | + | }, | |
| 2409 | + | "claim_issue": { | |
| 2410 | + | "request": { | |
| 2411 | + | "session_id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2", | |
| 2412 | + | "note": "Taking the argument parsing first.", | |
| 2413 | + | "ttl_minutes": 30 | |
| 2414 | + | }, | |
| 2415 | + | "response": { | |
| 2416 | + | "claim": { | |
| 2417 | + | "id": "clm_01m4b3d5f7h9k1m3p5r7t9w1y3", | |
| 2418 | + | "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 2419 | + | "issue": 12, | |
| 2420 | + | "session_id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2", | |
| 2421 | + | "actor": { | |
| 2422 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 2423 | + | "username": "syntaqx" | |
| 2424 | + | }, | |
| 2425 | + | "harness": "claude_code", | |
| 2426 | + | "note": "Taking the argument parsing first.", | |
| 2427 | + | "state": "active", | |
| 2428 | + | "created_at": "2026-10-12T14:03:02.771Z", | |
| 2429 | + | "expires_at": "2026-10-12T15:01:40.518Z", | |
| 2430 | + | "ended_at": null, | |
| 2431 | + | "end_reason": null, | |
| 2432 | + | "handoff_to": null | |
| 2433 | + | }, | |
| 2434 | + | "others": [ | |
| 2435 | + | { | |
| 2436 | + | "id": "clm_01m4b2a4c6e8g0j2l4n6q8s0u2", | |
| 2437 | + | "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 2438 | + | "issue": 12, | |
| 2439 | + | "session_id": null, | |
| 2440 | + | "actor": { | |
| 2441 | + | "id": "usr_01kkp2v7d4fh8m3q6t9w2z5c8e", | |
| 2442 | + | "username": "ana" | |
| 2443 | + | }, | |
| 2444 | + | "harness": null, | |
| 2445 | + | "note": "Looking at the tests.", | |
| 2446 | + | "state": "active", | |
| 2447 | + | "created_at": "2026-10-12T13:50:12.000Z", | |
| 2448 | + | "expires_at": "2026-10-12T14:20:12.000Z", | |
| 2449 | + | "ended_at": null, | |
| 2450 | + | "end_reason": null, | |
| 2451 | + | "handoff_to": null | |
| 2452 | + | } | |
| 2453 | + | ], | |
| 2454 | + | "blocked_by": [ | |
| 2455 | + | { | |
| 2456 | + | "number": 9, | |
| 2457 | + | "title": "Read arguments with a parser", | |
| 2458 | + | "state": "open", | |
| 2459 | + | "claims": [ | |
| 2460 | + | { | |
| 2461 | + | "id": "clm_01m4b2a4c6e8g0j2l4n6q8s0u2", | |
| 2462 | + | "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 2463 | + | "issue": 9, | |
| 2464 | + | "session_id": null, | |
| 2465 | + | "actor": { | |
| 2466 | + | "id": "usr_01kkp2v7d4fh8m3q6t9w2z5c8e", | |
| 2467 | + | "username": "ana" | |
| 2468 | + | }, | |
| 2469 | + | "harness": null, | |
| 2470 | + | "note": null, | |
| 2471 | + | "state": "active", | |
| 2472 | + | "created_at": "2026-10-12T13:50:12.000Z", | |
| 2473 | + | "expires_at": "2026-10-12T14:20:12.000Z", | |
| 2474 | + | "ended_at": null, | |
| 2475 | + | "end_reason": null, | |
| 2476 | + | "handoff_to": null | |
| 2477 | + | } | |
| 2478 | + | ], | |
| 2479 | + | "pulls": [ | |
| 2480 | + | { | |
| 2481 | + | "number": 11, | |
| 2482 | + | "title": "Parse arguments with clap", | |
| 2483 | + | "status": "open" | |
| 2484 | + | } | |
| 2485 | + | ] | |
| 2486 | + | } | |
| 2487 | + | ] | |
| 2488 | + | }, | |
| 2489 | + | "notes": "A claim is advisory: it is made even when `others` lists someone already on the issue. Claiming an issue you hold renews your claim. Refused with `409` for a closed issue. See [Claims](/guides/sessions/#claims)." | |
| 2490 | + | }, | |
| 2491 | + | "release_claim": { | |
| 2492 | + | "request": { | |
| 2493 | + | "reason": "Ana has it covered." | |
| 2494 | + | }, | |
| 2495 | + | "response": { | |
| 2496 | + | "id": "clm_01m4b3d5f7h9k1m3p5r7t9w1y3", | |
| 2497 | + | "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 2498 | + | "issue": 12, | |
| 2499 | + | "session_id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2", | |
| 2500 | + | "actor": { | |
| 2501 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 2502 | + | "username": "syntaqx" | |
| 2503 | + | }, | |
| 2504 | + | "harness": "claude_code", | |
| 2505 | + | "note": "Taking the argument parsing first.", | |
| 2506 | + | "state": "released", | |
| 2507 | + | "created_at": "2026-10-12T14:03:02.771Z", | |
| 2508 | + | "expires_at": "2026-10-12T15:01:40.518Z", | |
| 2509 | + | "ended_at": "2026-10-12T14:12:30.000Z", | |
| 2510 | + | "end_reason": "Ana has it covered.", | |
| 2511 | + | "handoff_to": null | |
| 2512 | + | } | |
| 2513 | + | }, | |
| 2514 | + | "hand_off_issue": { | |
| 2515 | + | "request": { | |
| 2516 | + | "to": "ana", | |
| 2517 | + | "note": "Parsing is done and tested; the README still needs the new usage." | |
| 2518 | + | }, | |
| 2519 | + | "response": { | |
| 2520 | + | "id": "clm_01m4b3d5f7h9k1m3p5r7t9w1y3", | |
| 2521 | + | "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 2522 | + | "issue": 12, | |
| 2523 | + | "session_id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2", | |
| 2524 | + | "actor": { | |
| 2525 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 2526 | + | "username": "syntaqx" | |
| 2527 | + | }, | |
| 2528 | + | "harness": "claude_code", | |
| 2529 | + | "note": "Parsing is done and tested; the README still needs the new usage.", | |
| 2530 | + | "state": "handed_off", | |
| 2531 | + | "created_at": "2026-10-12T14:03:02.771Z", | |
| 2532 | + | "expires_at": "2026-10-12T15:01:40.518Z", | |
| 2533 | + | "ended_at": "2026-10-12T14:38:00.000Z", | |
| 2534 | + | "end_reason": "handed_off", | |
| 2535 | + | "handoff_to": "ana" | |
| 2536 | + | } | |
| 2537 | + | }, | |
| 2538 | + | "list_claims": { | |
| 2539 | + | "query": { | |
| 2540 | + | "state": "active" | |
| 2541 | + | }, | |
| 2542 | + | "response": [ | |
| 2543 | + | { | |
| 2544 | + | "id": "clm_01m4b3d5f7h9k1m3p5r7t9w1y3", | |
| 2545 | + | "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 2546 | + | "issue": 12, | |
| 2547 | + | "session_id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2", | |
| 2548 | + | "actor": { | |
| 2549 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 2550 | + | "username": "syntaqx" | |
| 2551 | + | }, | |
| 2552 | + | "harness": "claude_code", | |
| 2553 | + | "note": "Taking the argument parsing first.", | |
| 2554 | + | "state": "active", | |
| 2555 | + | "created_at": "2026-10-12T14:03:02.771Z", | |
| 2556 | + | "expires_at": "2026-10-12T15:01:40.518Z", | |
| 2557 | + | "ended_at": null, | |
| 2558 | + | "end_reason": null, | |
| 2559 | + | "handoff_to": null | |
| 2560 | + | }, | |
| 2561 | + | { | |
| 2562 | + | "id": "clm_01m4b2a4c6e8g0j2l4n6q8s0u2", | |
| 2563 | + | "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 2564 | + | "issue": 12, | |
| 2565 | + | "session_id": null, | |
| 2566 | + | "actor": { | |
| 2567 | + | "id": "usr_01kkp2v7d4fh8m3q6t9w2z5c8e", | |
| 2568 | + | "username": "ana" | |
| 2569 | + | }, | |
| 2570 | + | "harness": null, | |
| 2571 | + | "note": "Looking at the tests.", | |
| 2572 | + | "state": "active", | |
| 2573 | + | "created_at": "2026-10-12T13:50:12.000Z", | |
| 2574 | + | "expires_at": "2026-10-12T14:20:12.000Z", | |
| 2575 | + | "ended_at": null, | |
| 2576 | + | "end_reason": null, | |
| 2577 | + | "handoff_to": null | |
| 2578 | + | } | |
| 2579 | + | ] | |
| 2580 | + | }, | |
| 2581 | + | "get_work": { | |
| 2582 | + | "response": { | |
| 2583 | + | "issue": { | |
| 2584 | + | "id": "iss_01m43shrzpfe49x74ga7sj1c6v", | |
| 2585 | + | "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 2586 | + | "number": 12, | |
| 2587 | + | "title": "Greeting should name the caller", | |
| 2588 | + | "body": "Take a name from the first argument; fall back to world.\n\n## Definition of done\n\n- `cargo test` passes.\n- The README shows the new usage.", | |
| 2589 | + | "labels": [ | |
| 2590 | + | "feature", | |
| 2591 | + | "good first issue" | |
| 2592 | + | ], | |
| 2593 | + | "state": "open", | |
| 2594 | + | "reason": null, | |
| 2595 | + | "resolved_by": null, | |
| 2596 | + | "author": { | |
| 2597 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 2598 | + | "username": "syntaqx", | |
| 2599 | + | "display_username": "syntaqx", | |
| 2600 | + | "kind": "user", | |
| 2601 | + | "verified": false, | |
| 2602 | + | "workspaces": [] | |
| 2603 | + | }, | |
| 2604 | + | "requested_by": null, | |
| 2605 | + | "created_at": "2026-10-01T18:04:11.482Z", | |
| 2606 | + | "updated_at": "2026-10-01T18:09:47.305Z", | |
| 2607 | + | "closed_at": null, | |
| 2608 | + | "pull_count": 0, | |
| 2609 | + | "comment_count": 0, | |
| 2610 | + | "assignees": [ | |
| 2611 | + | "syntaqx" | |
| 2612 | + | ], | |
| 2613 | + | "blocked_by": [ | |
| 2614 | + | 9 | |
| 2615 | + | ], | |
| 2616 | + | "queued": false, | |
| 2617 | + | "agent": null, | |
| 2618 | + | "milestone": { | |
| 2619 | + | "number": 3, | |
| 2620 | + | "title": "Launch" | |
| 2621 | + | } | |
| 2622 | + | }, | |
| 2623 | + | "definition_of_done": [ | |
| 2624 | + | "`cargo test` passes.", | |
| 2625 | + | "The README shows the new usage." | |
| 2626 | + | ], | |
| 2627 | + | "required_checks": [ | |
| 2628 | + | "CI" | |
| 2629 | + | ], | |
| 2630 | + | "claims": [ | |
| 2631 | + | { | |
| 2632 | + | "id": "clm_01m4b3d5f7h9k1m3p5r7t9w1y3", | |
| 2633 | + | "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 2634 | + | "issue": 12, | |
| 2635 | + | "session_id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2", | |
| 2636 | + | "actor": { | |
| 2637 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 2638 | + | "username": "syntaqx" | |
| 2639 | + | }, | |
| 2640 | + | "harness": "claude_code", | |
| 2641 | + | "note": "Taking the argument parsing first.", | |
| 2642 | + | "state": "active", | |
| 2643 | + | "created_at": "2026-10-12T14:03:02.771Z", | |
| 2644 | + | "expires_at": "2026-10-12T15:01:40.518Z", | |
| 2645 | + | "ended_at": null, | |
| 2646 | + | "end_reason": null, | |
| 2647 | + | "handoff_to": null | |
| 2648 | + | } | |
| 2649 | + | ], | |
| 2650 | + | "sessions": [ | |
| 2651 | + | { | |
| 2652 | + | "id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2", | |
| 2653 | + | "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 2654 | + | "issue": 12, | |
| 2655 | + | "pull": 14, | |
| 2656 | + | "branch": "greet-caller", | |
| 2657 | + | "actor": { | |
| 2658 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 2659 | + | "username": "syntaqx" | |
| 2660 | + | }, | |
| 2661 | + | "harness": "claude_code", | |
| 2662 | + | "harness_session_id": "5b0c1f9e-3d2a-4e8b-9f61-0c7d2a9e4b13", | |
| 2663 | + | "title": "Make the greeting name the caller", | |
| 2664 | + | "state": "active", | |
| 2665 | + | "head_commit": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13", | |
| 2666 | + | "started_at": "2026-10-12T14:02:11.204Z", | |
| 2667 | + | "last_seen_at": "2026-10-12T14:31:40.518Z", | |
| 2668 | + | "ended_at": null, | |
| 2669 | + | "end_reason": null | |
| 2670 | + | } | |
| 2671 | + | ], | |
| 2672 | + | "pulls": [ | |
| 2673 | + | { | |
| 2674 | + | "number": 14, | |
| 2675 | + | "title": "Greeting should name the caller", | |
| 2676 | + | "status": "draft", | |
| 2677 | + | "branch": "greet-caller", | |
| 2678 | + | "head_commit": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13", | |
| 2679 | + | "author": { | |
| 2680 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 2681 | + | "username": "syntaqx" | |
| 2682 | + | }, | |
| 2683 | + | "check_status": null, | |
| 2684 | + | "required_checks": [ | |
| 2685 | + | { | |
| 2686 | + | "name": "CI", | |
| 2687 | + | "state": "success", | |
| 2688 | + | "description": "CI passed", | |
| 2689 | + | "target_url": "https://g1t.sh/syntaqx/hello/actions/runs/run_01m43sw8e2g6j0m4q8t2x6a0c4" | |
| 2690 | + | } | |
| 2691 | + | ], | |
| 2692 | + | "mergeable": "clean", | |
| 2693 | + | "overlaps": [ | |
| 2694 | + | { | |
| 2695 | + | "number": 11, | |
| 2696 | + | "title": "Parse arguments with clap", | |
| 2697 | + | "issue": 9, | |
| 2698 | + | "paths": [ | |
| 2699 | + | "src/main.rs" | |
| 2700 | + | ] | |
| 2701 | + | } | |
| 2702 | + | ], | |
| 2703 | + | "queue": null | |
| 2704 | + | } | |
| 2705 | + | ], | |
| 2706 | + | "blocked_by": [ | |
| 2707 | + | { | |
| 2708 | + | "number": 9, | |
| 2709 | + | "title": "Read arguments with a parser", | |
| 2710 | + | "state": "open", | |
| 2711 | + | "claims": [ | |
| 2712 | + | { | |
| 2713 | + | "id": "clm_01m4b2a4c6e8g0j2l4n6q8s0u2", | |
| 2714 | + | "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 2715 | + | "issue": 9, | |
| 2716 | + | "session_id": null, | |
| 2717 | + | "actor": { | |
| 2718 | + | "id": "usr_01kkp2v7d4fh8m3q6t9w2z5c8e", | |
| 2719 | + | "username": "ana" | |
| 2720 | + | }, | |
| 2721 | + | "harness": null, | |
| 2722 | + | "note": null, | |
| 2723 | + | "state": "active", | |
| 2724 | + | "created_at": "2026-10-12T13:50:12.000Z", | |
| 2725 | + | "expires_at": "2026-10-12T14:20:12.000Z", | |
| 2726 | + | "ended_at": null, | |
| 2727 | + | "end_reason": null, | |
| 2728 | + | "handoff_to": null | |
| 2729 | + | } | |
| 2730 | + | ], | |
| 2731 | + | "pulls": [ | |
| 2732 | + | { | |
| 2733 | + | "number": 11, | |
| 2734 | + | "title": "Parse arguments with clap", | |
| 2735 | + | "status": "open" | |
| 2736 | + | } | |
| 2737 | + | ] | |
| 2738 | + | } | |
| 2739 | + | ], | |
| 2740 | + | "blocks": [ | |
| 2741 | + | { | |
| 2742 | + | "number": 15, | |
| 2743 | + | "title": "Greet in the caller's language", | |
| 2744 | + | "state": "open" | |
| 2745 | + | } | |
| 2746 | + | ], | |
| 2747 | + | "decisions": [ | |
| 2748 | + | { | |
| 2749 | + | "id": "dec_01m4b3e7g9j1l3n5q7s9u1w3y5", | |
| 2750 | + | "issue": 12, | |
| 2751 | + | "pull": 14, | |
| 2752 | + | "session_id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2", | |
| 2753 | + | "actor": "syntaqx", | |
| 2754 | + | "kind": "proposal", | |
| 2755 | + | "confidence": "claimed", | |
| 2756 | + | "summary": "Read the name with the argument parser #9 brings, rather than by hand.", | |
| 2757 | + | "evidence": { | |
| 2758 | + | "commit": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13" | |
| 2759 | + | }, | |
| 2760 | + | "created_at": "2026-10-12T14:20:00.000Z" | |
| 2761 | + | } | |
| 2762 | + | ], | |
| 2763 | + | "last_integration_failure": null, | |
| 2764 | + | "recent": [ | |
| 2765 | + | { | |
| 2766 | + | "id": "evt_01m4b3d5f7h9k1m3p5r7t9w1y4", | |
| 2767 | + | "type": "work.claimed", | |
| 2768 | + | "source": "work", | |
| 2769 | + | "time": "2026-10-12T14:03:02.771Z", | |
| 2770 | + | "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 2771 | + | "actor": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 2772 | + | "data": { | |
| 2773 | + | "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 2774 | + | "number": 12, | |
| 2775 | + | "issue": 12, | |
| 2776 | + | "claim": { | |
| 2777 | + | "id": "clm_01m4b3d5f7h9k1m3p5r7t9w1y3", | |
| 2778 | + | "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 2779 | + | "issue": 12, | |
| 2780 | + | "session_id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2", | |
| 2781 | + | "actor": { | |
| 2782 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 2783 | + | "username": "syntaqx" | |
| 2784 | + | }, | |
| 2785 | + | "harness": "claude_code", | |
| 2786 | + | "note": "Taking the argument parsing first.", | |
| 2787 | + | "state": "active", | |
| 2788 | + | "created_at": "2026-10-12T14:03:02.771Z", | |
| 2789 | + | "expires_at": "2026-10-12T15:01:40.518Z", | |
| 2790 | + | "ended_at": null, | |
| 2791 | + | "end_reason": null, | |
| 2792 | + | "handoff_to": null | |
| 2793 | + | }, | |
| 2794 | + | "others": [ | |
| 2795 | + | { | |
| 2796 | + | "id": "clm_01m4b2a4c6e8g0j2l4n6q8s0u2", | |
| 2797 | + | "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 2798 | + | "issue": 12, | |
| 2799 | + | "session_id": null, | |
| 2800 | + | "actor": { | |
| 2801 | + | "id": "usr_01kkp2v7d4fh8m3q6t9w2z5c8e", | |
| 2802 | + | "username": "ana" | |
| 2803 | + | }, | |
| 2804 | + | "harness": null, | |
| 2805 | + | "note": "Looking at the tests.", | |
| 2806 | + | "state": "active", | |
| 2807 | + | "created_at": "2026-10-12T13:50:12.000Z", | |
| 2808 | + | "expires_at": "2026-10-12T14:20:12.000Z", | |
| 2809 | + | "ended_at": null, | |
| 2810 | + | "end_reason": null, | |
| 2811 | + | "handoff_to": null | |
| 2812 | + | } | |
| 2813 | + | ] | |
| 2814 | + | } | |
| 2815 | + | } | |
| 2816 | + | ] | |
| 2817 | + | }, | |
| 2818 | + | "notes": "`pulls` are the issue's pull requests, oldest first; `required_checks`, `mergeable`, `overlaps` and `queue` (its `state` and `position` in the merge queue, 1 at the front) are filled in for those still open or draft. `recent` holds up to 20 coordination events about the issue or its pull requests, newest first. See [Picking up an issue](/guides/sessions/#picking-up-an-issue)." | |
| 2819 | + | }, | |
| 2230 | 2820 | "record_decision": { | |
| 2231 | 2821 | "request": { | |
| 2232 | 2822 | "kind": "fix", | |
| 18 | 18 | use crate::checks::ChecksOp; | |
| 19 | 19 | use crate::rules::RulesOp; | |
| 20 | 20 | use crate::deploy_keys::DeployKeysOp; | |
| 21 | + | use crate::coordination::CoordinationOp; | |
| 21 | 22 | ||
| 22 | 23 | /// A key as `#[serde(rename_all = "camelCase")]` writes it. | |
| 23 | 24 | fn camel_key(key: &str) -> String { | |
| ⋯ | |||
| 234 | 235 | Op::Checks(ChecksOp::ListCheckRunsForRef) => through::<checks::CheckRunList>(op, sent), | |
| 235 | 236 | Op::Checks(ChecksOp::ListCheckSuitesForRef) => through::<checks::CheckSuiteList>(op, sent), | |
| 236 | 237 | Op::Checks(ChecksOp::GetCheckSuite) => through::<checks::CommitCheckSuite>(op, sent), | |
| 238 | + | Op::Coordination(CoordinationOp::StartSession | CoordinationOp::UpdateSession | CoordinationOp::EndSession) => { | |
| 239 | + | through::<work::Session>(op, sent) | |
| 240 | + | } | |
| 241 | + | Op::Coordination(CoordinationOp::GetSession) => through::<work::SessionDetail>(op, sent), | |
| 242 | + | Op::Coordination(CoordinationOp::ListSessions) => through::<Vec<work::Session>>(op, sent), | |
| 243 | + | Op::Coordination(CoordinationOp::RecordSessionEntries) => through::<work::Recorded>(op, sent), | |
| 244 | + | Op::Coordination(CoordinationOp::ClaimIssue) => through::<work::Claimed>(op, sent), | |
| 245 | + | Op::Coordination(CoordinationOp::ReleaseClaim | CoordinationOp::HandOffIssue) => through::<work::Claim>(op, sent), | |
| 246 | + | Op::Coordination(CoordinationOp::ListClaims) => through::<Vec<work::Claim>>(op, sent), | |
| 247 | + | Op::Coordination(CoordinationOp::GetWork) => through::<work::WorkContext>(op, sent), | |
| 237 | 248 | _ => sent, | |
| 238 | 249 | } | |
| 239 | 250 | } | |
| 10 | 10 | use crate::telemetry::TelemetryOp; | |
| 11 | 11 | use crate::packages::PackagesOp; | |
| 12 | 12 | use crate::folios::FoliosOp; | |
| 13 | + | use crate::coordination::CoordinationOp; | |
| 13 | 14 | use crate::protection::ProtectionOp; | |
| 14 | 15 | use crate::token_policy::TokenOp; | |
| 15 | 16 | use crate::operations::Op; | |
| ⋯ | |||
| 592 | 593 | Op::ImportIssue, | |
| 593 | 594 | &[], | |
| 594 | 595 | ), | |
| 596 | + | // Coordinating work: claims on issues, an issue's whole context, and | |
| 597 | + | // sessions of people's own harnesses. | |
| 598 | + | route("GET", "/repos/:owner/:name/issues/:number/work", Op::Coordination(CoordinationOp::GetWork), &[]), | |
| 599 | + | route("POST", "/repos/:owner/:name/issues/:number/claim", Op::Coordination(CoordinationOp::ClaimIssue), &[]), | |
| 600 | + | route("POST", "/repos/:owner/:name/issues/:number/release", Op::Coordination(CoordinationOp::ReleaseClaim), &[]), | |
| 601 | + | route("POST", "/repos/:owner/:name/issues/:number/handoff", Op::Coordination(CoordinationOp::HandOffIssue), &[]), | |
| 602 | + | route( | |
| 603 | + | "GET", | |
| 604 | + | "/repos/:owner/:name/claims", | |
| 605 | + | Op::Coordination(CoordinationOp::ListClaims), | |
| 606 | + | &[("state", "state"), ("issue", "issue")], | |
| 607 | + | ), | |
| 608 | + | route("POST", "/repos/:owner/:name/sessions", Op::Coordination(CoordinationOp::StartSession), &[]), | |
| 609 | + | route( | |
| 610 | + | "GET", | |
| 611 | + | "/repos/:owner/:name/sessions", | |
| 612 | + | Op::Coordination(CoordinationOp::ListSessions), | |
| 613 | + | &[("state", "state"), ("issue", "issue"), ("pull", "pull")], | |
| 614 | + | ), | |
| 615 | + | route("GET", "/repos/:owner/:name/sessions/:id", Op::Coordination(CoordinationOp::GetSession), &[("after", "after")]), | |
| 616 | + | route("PATCH", "/repos/:owner/:name/sessions/:id", Op::Coordination(CoordinationOp::UpdateSession), &[]), | |
| 617 | + | route("POST", "/repos/:owner/:name/sessions/:id/end", Op::Coordination(CoordinationOp::EndSession), &[]), | |
| 618 | + | route( | |
| 619 | + | "POST", | |
| 620 | + | "/repos/:owner/:name/sessions/:id/entries", | |
| 621 | + | Op::Coordination(CoordinationOp::RecordSessionEntries), | |
| 622 | + | &[], | |
| 623 | + | ), | |
| 595 | 624 | route( | |
| 596 | 625 | "GET", | |
| 597 | 626 | "/repos/:owner/:name/context", | |
| 26 | 26 | use crate::telemetry::TelemetryOp; | |
| 27 | 27 | use crate::packages::PackagesOp; | |
| 28 | 28 | use crate::folios::FoliosOp; | |
| 29 | + | use crate::coordination::CoordinationOp; | |
| 29 | 30 | use crate::protection::ProtectionOp; | |
| 30 | 31 | use crate::token_policy::TokenOp; | |
| 31 | 32 | use crate::operations::Op; | |
| ⋯ | |||
| 135 | 136 | Tool { | |
| 136 | 137 | name: "issue", | |
| 137 | 138 | title: "Issues", | |
| 138 | − | description: "Issues: what should change. Read one before working on it to see the pull requests already made for it. Issues and pull requests share numbers; `comment` works on either.", | |
| 139 | + | description: "Issues: what should change. Before working on one, `work` gives everything about it (definition of done, who is on it, its pull requests, what it waits on); `claim` says you are on it (advisory and expiring, never a lock), `release` or `handoff` when you stop. `update` with blocked_by sets the issues that must land first. Issues and pull requests share numbers; `comment` works on either.", | |
| 139 | 140 | default_action: None, | |
| 140 | 141 | actions: &[ | |
| 141 | 142 | a("list", Op::ListIssues, "Issues on a repository, newest first"), | |
| 142 | 143 | a("get", Op::GetIssue, "One issue with comments and its pull requests"), | |
| 143 | 144 | a("create", Op::CreateIssue, "Open an issue"), | |
| 144 | − | a("update", Op::UpdateIssue, "Change title, body, labels, milestone or assignees"), | |
| 145 | + | a("update", Op::UpdateIssue, "Change title, body, labels, milestone, assignees or blocked_by"), | |
| 145 | 146 | a("labels", Op::ListIssueLabels, "The labels an issue or pull request carries"), | |
| 146 | 147 | a("add_labels", Op::AddIssueLabels, "Add labels to an issue or pull request"), | |
| 147 | 148 | a("set_labels", Op::SetIssueLabels, "Replace the labels of an issue or pull request"), | |
| ⋯ | |||
| 152 | 153 | a("edit_comment", Op::EditComment, "Change a comment's text: your own, or any with the Maintain role"), | |
| 153 | 154 | a("delete_comment", Op::DeleteComment, "Delete a comment: your own, or any with the Maintain role"), | |
| 154 | 155 | a("import", Op::ImportIssue, "Open an issue from a Jira, Linear or Sentry item"), | |
| 156 | + | a("work", Op::Coordination(CoordinationOp::GetWork), "Everything to pick it up: done means, claims, sessions, pull requests, blockers, decisions, recent events"), | |
| 157 | + | a("claim", Op::Coordination(CoordinationOp::ClaimIssue), "Say you are working on it; lists who else is, and what it is blocked by"), | |
| 158 | + | a("release", Op::Coordination(CoordinationOp::ReleaseClaim), "Stop working on it"), | |
| 159 | + | a("handoff", Op::Coordination(CoordinationOp::HandOffIssue), "Hand it off, to someone or anyone, with a note on where it stands"), | |
| 160 | + | a("list_claims", Op::Coordination(CoordinationOp::ListClaims), "Claims on a repository's issues: who is working on what"), | |
| 155 | 161 | ], | |
| 156 | 162 | }, | |
| 157 | 163 | Tool { | |
| ⋯ | |||
| 181 | 187 | ], | |
| 182 | 188 | }, | |
| 183 | 189 | Tool { | |
| 190 | + | name: "session", | |
| 191 | + | title: "Sessions", | |
| 192 | + | description: "Your harness session on a repository, so others see who is working on what: `start` once (idempotent on harness_session_id), `update` as a heartbeat or to attach an issue, pull request, branch or head commit, `record` prompts, reasoning and tool calls (idempotent on key), `end` when done. A session goes idle after 15 minutes without a heartbeat and ends after 2 hours.", | |
| 193 | + | default_action: None, | |
| 194 | + | actions: &[ | |
| 195 | + | a("start", Op::Coordination(CoordinationOp::StartSession), "Start one, or get the one started with this harness_session_id"), | |
| 196 | + | a("update", Op::Coordination(CoordinationOp::UpdateSession), "Heartbeat; or attach an issue, pull request, branch, head commit or title"), | |
| 197 | + | a("record", Op::Coordination(CoordinationOp::RecordSessionEntries), "Record prompts, reasoning and tool calls; a key makes each idempotent"), | |
| 198 | + | a("end", Op::Coordination(CoordinationOp::EndSession), "End it"), | |
| 199 | + | a("get", Op::Coordination(CoordinationOp::GetSession), "One session and its entries"), | |
| 200 | + | a("list", Op::Coordination(CoordinationOp::ListSessions), "A repository's sessions: active, idle or ended, by issue or pull request"), | |
| 201 | + | ], | |
| 202 | + | }, | |
| 203 | + | Tool { | |
| 184 | 204 | name: "memory", | |
| 185 | 205 | title: "Memory", | |
| 186 | 206 | description: "What the project and its workspace remember for the next agent: how to build, conventions, decisions, traps. Recall before you start; remember one short fact at a time, never a secret.", | |
| ⋯ | |||
| 849 | 869 | assert!(tool.action(default).is_some(), "{}", tool.name); | |
| 850 | 870 | } | |
| 851 | 871 | } | |
| 852 | − | assert!(TOOLS.len() <= 18, "{} tools", TOOLS.len()); | |
| 872 | + | assert!(TOOLS.len() <= 19, "{} tools", TOOLS.len()); | |
| 853 | 873 | } | |
| 854 | 874 | ||
| 855 | 875 | #[test] | |
| ⋯ | |||
| 914 | 934 | assert_eq!(tool["annotations"]["destructiveHint"], false); | |
| 915 | 935 | } | |
| 916 | 936 | let issue = tools.iter().find(|tool| tool["name"] == "issue").unwrap(); | |
| 917 | − | assert_eq!(issue["inputSchema"]["properties"]["action"]["enum"], json!(["list", "get", "labels"])); | |
| 937 | + | assert_eq!(issue["inputSchema"]["properties"]["action"]["enum"], json!(["list", "get", "labels", "work", "list_claims"])); | |
| 918 | 938 | } | |
| 919 | 939 | ||
| 920 | 940 | #[test] | |
| 154 | 154 | items: [ | |
| 155 | 155 | { label: 'Connect a coding agent', slug: 'guides/bring-your-own-agent' }, | |
| 156 | 156 | { label: 'Claude Code plugin', slug: 'guides/claude-code-plugin' }, | |
| 157 | + | { label: 'Sessions, claims and dependencies', slug: 'guides/sessions' }, | |
| 157 | 158 | { label: 'Sessions and why-blame', slug: 'guides/why-blame' }, | |
| 158 | 159 | { label: 'Memory', slug: 'guides/memory' }, | |
| 159 | 160 | ], |
| 15 | 15 | | Push code | git over HTTPS, with an [access token](/guides/authentication/#access-tokens) | | |
| 16 | 16 | | Start the agent when something happens | [Webhooks](/guides/webhooks/), or a [workflow](/guides/actions/) on `issues` or `issue_comment` | | |
| 17 | 17 | | Show people how a change was made | The pull request's [session](/guides/why-blame/#sessions) | | |
| 18 | + | | See who is working on what, and not collide | [Sessions, claims and dependencies](/guides/sessions/) | | |
| 18 | 19 | | Pass what one agent learned to the next | [Memory](/guides/memory/) | | |
| 19 | 20 | | Script anything else | The [REST API](/reference/api/), which has the same operations as the tools | | |
| 20 | 21 | ||
| ⋯ | |||
| 74 | 75 | ||
| 75 | 76 | ## How an agent works on an issue | |
| 76 | 77 | ||
| 77 | − | 1. `issue` with `get`, to read the description, including what done means | |
| 78 | − | if it says, and to see which pull requests already exist for it. | |
| 78 | + | 1. `issue` with `work`, to load everything about it in one call: what | |
| 79 | + | done means, who has claimed it, the pull requests already made for it, | |
| 80 | + | and what it is blocked by. Then `issue` with `claim`, which also says | |
| 81 | + | who else is on it. | |
| 79 | 82 | 2. `memory` with `recall`, to read what the project remembers: how to | |
| 80 | 83 | build, conventions and traps. | |
| 81 | 84 | 3. `pull_request` with `create` and the issue's number. This opens a draft | |
| ⋯ | |||
| 285 | 288 | | --- | --- | | |
| 286 | 289 | | `search` | Code, repositories, issues, pull requests and people across g1t; a tracker's ticket | | |
| 287 | 290 | | `repository` | Repositories, their settings, rulesets, branches, labels, milestones and releases | | |
| 288 | − | | `issue` | Issues, labels and comments | | |
| 291 | + | | `issue` | Issues, labels and comments; an issue's whole context, and claims | | |
| 289 | 292 | | `pull_request` | Pull requests: create, record and read sessions, review, merge, the merge queue | | |
| 293 | + | | `session` | Your harness's session: start, heartbeat, record, end; who else is working | | |
| 290 | 294 | | `memory` | `recall` and `remember` | | |
| 291 | 295 | | `workflow` | Workflow runs and logs, checks, deployments, environments and runners | | |
| 292 | 296 | | `observability` | A project's requests, logs, traces, errors and metrics in production | | |
| 1 | + | --- | |
| 2 | + | title: Sessions, claims and dependencies | |
| 3 | + | description: How agents and people on one repository see who is working on what, with sessions of their own harnesses, advisory claims on issues, what an issue is blocked by, and the one call that loads an issue's whole context. | |
| 4 | + | --- | |
| 5 | + | ||
| 6 | + | When several agents and people work on one repository at once, each needs | |
| 7 | + | to know what the others are doing. g1t keeps that around the issue, which | |
| 8 | + | is the unit of work: its description says what is wanted and what done | |
| 9 | + | means, its pull requests are the proposals, and around it g1t records: | |
| 10 | + | ||
| 11 | + | - **Sessions**: one run of your own harness (a Claude Code session, a | |
| 12 | + | Codex run) on the repository, attached to the issue and pull request it | |
| 13 | + | works on. | |
| 14 | + | - **Claims**: an advisory, expiring "I'm working on #12". Never a lock. | |
| 15 | + | - **Dependencies**: the issues that have to land before this one. | |
| 16 | + | ||
| 17 | + | Nothing here waits on a harness. A harness that crashes, loses its | |
| 18 | + | network or sends the same thing twice does no harm: sessions and claims | |
| 19 | + | that go quiet are read as idle, ended or expired the next time anyone | |
| 20 | + | looks, and starting a session or recording an entry again is a no-op. | |
| 21 | + | What git and g1t record (commits, pull requests, checks, merges) is the | |
| 22 | + | truth; sessions are the account of how it got there. | |
| 23 | + | ||
| 24 | + | ## Picking up an issue | |
| 25 | + | ||
| 26 | + | Before working on an issue, load its whole context in one call: `issue` | |
| 27 | + | with `"action": "work"` over MCP, or | |
| 28 | + | [`GET /repos/{owner}/{name}/issues/{number}/work`](/reference/api/claims-and-work/get-work/): | |
| 29 | + | ||
| 30 | + | ```sh | |
| 31 | + | curl https://api.g1t.sh/repos/acme/web/issues/12/work \ | |
| 32 | + | -H "Authorization: Bearer $G1T_TOKEN" | |
| 33 | + | ``` | |
| 34 | + | ||
| 35 | + | | Field | What it holds | | |
| 36 | + | | --- | --- | | |
| 37 | + | | `issue` | The issue, with its body. | | |
| 38 | + | | `definition_of_done` | The items under its **Definition of done** heading. | | |
| 39 | + | | `required_checks` | The checks every pull request into the default branch must pass. | | |
| 40 | + | | `claims` | Who says they are working on it now. | | |
| 41 | + | | `sessions` | Sessions on it or its pull requests, most recently heard from first. | | |
| 42 | + | | `pulls` | Its pull requests. Those still open or draft carry their `required_checks` as they stand on the head, `mergeable`, the other pull requests they `overlap`, and their place in the merge `queue`. | | |
| 43 | + | | `blocked_by` | Each issue it waits on, with that issue's claims and pull requests. | | |
| 44 | + | | `blocks` | The issues waiting on it. | | |
| 45 | + | | `decisions` | [Decision records](/guides/why-blame/#decision-records) on it and its pull requests, newest first. | | |
| 46 | + | | `last_integration_failure` | The latest `integration_failure` decision among them, if any: the combination that failed in the merge queue, and the check. | | |
| 47 | + | | `recent` | Its latest 20 coordination events, newest first. | | |
| 48 | + | ||
| 49 | + | Then claim it, and start (or attach) your session. | |
| 50 | + | ||
| 51 | + | ## Sessions | |
| 52 | + | ||
| 53 | + | A session is one run of your harness on a repository. Start one when the | |
| 54 | + | harness starts, keep it alive while it works, and end it when it stops. | |
| 55 | + | ||
| 56 | + | 1. **Start** it with `session` `start`, or | |
| 57 | + | [`POST /repos/{owner}/{name}/sessions`](/reference/api/sessions/start-session/), | |
| 58 | + | naming the `harness` (`claude_code`, `codex`, `cursor`, `copilot` or | |
| 59 | + | `other`) and the harness's own id for the session as | |
| 60 | + | `harness_session_id`. Starting again with the same id returns the | |
| 61 | + | session already started, so a hook can call it every time without | |
| 62 | + | checking first. Give `issue`, `pull` and `branch` when you know them. | |
| 63 | + | 2. **Heartbeat** with `session` `update`, or | |
| 64 | + | [`PATCH /repos/{owner}/{name}/sessions/{id}`](/reference/api/sessions/update-session/) | |
| 65 | + | with an empty body. The same call attaches the session to an issue or | |
| 66 | + | pull request, or records the `head_commit` it pushed. | |
| 67 | + | 3. **Record** what happens with `session` `record`, or | |
| 68 | + | [`POST /repos/{owner}/{name}/sessions/{id}/entries`](/reference/api/sessions/record-session-entries/): | |
| 69 | + | prompts, reasoning, tool calls and notes, up to 200 a request. Give each | |
| 70 | + | entry a `key`, and recording it again does nothing. | |
| 71 | + | 4. **End** it with `session` `end`, or | |
| 72 | + | [`POST /repos/{owner}/{name}/sessions/{id}/end`](/reference/api/sessions/end-session/). | |
| 73 | + | ||
| 74 | + | ```sh | |
| 75 | + | curl -X POST https://api.g1t.sh/repos/acme/web/sessions \ | |
| 76 | + | -H "Authorization: Bearer $G1T_TOKEN" \ | |
| 77 | + | -H "Content-Type: application/json" \ | |
| 78 | + | -d '{"harness": "claude_code", "harness_session_id": "5b0c1f9e-3d2a-4e8b-9f61-0c7d2a9e4b13", "issue": 12}' | |
| 79 | + | ``` | |
| 80 | + | ||
| 81 | + | A session's `state` is worked out from when it was last heard from. Every | |
| 82 | + | update and every batch of entries counts as a heartbeat: | |
| 83 | + | ||
| 84 | + | | State | Means | | |
| 85 | + | | --- | --- | | |
| 86 | + | | `active` | Heard from in the last 15 minutes. | | |
| 87 | + | | `idle` | Not heard from for 15 minutes. It may still come back. | | |
| 88 | + | | `ended` | Ended with `end_session`, or not heard from for 2 hours (`end_reason` `timed_out`). A heartbeat brings a timed-out session back; an ended one stays ended. | | |
| 89 | + | ||
| 90 | + | Entries of a session attached to a pull request show in that pull | |
| 91 | + | request's [session](/guides/why-blame/#sessions), beside what was recorded | |
| 92 | + | on it with `record_session`, so reviewers and why-blame see them. Entries | |
| 93 | + | recorded before the session named its pull request move onto it when it | |
| 94 | + | does. A session never records tool output unless you send it as a | |
| 95 | + | `tool_result` entry; a session is as visible as the repository, so keep | |
| 96 | + | secrets out of it. | |
| 97 | + | ||
| 98 | + | List a repository's sessions with `session` `list`, or | |
| 99 | + | [`GET /repos/{owner}/{name}/sessions`](/reference/api/sessions/list-sessions/) | |
| 100 | + | by `state`, `issue` or `pull`, and read one with its entries with | |
| 101 | + | `session` `get`. | |
| 102 | + | ||
| 103 | + | ## Claims | |
| 104 | + | ||
| 105 | + | A claim says you are working on an issue. It is advisory: anyone may claim | |
| 106 | + | an issue someone else has, and is told about them, so two agents can | |
| 107 | + | decide between themselves rather than collide. Nothing is refused because | |
| 108 | + | of a claim. | |
| 109 | + | ||
| 110 | + | - **Claim** an open issue with `issue` `claim`, or | |
| 111 | + | [`POST /repos/{owner}/{name}/issues/{number}/claim`](/reference/api/claims-and-work/claim-issue/). | |
| 112 | + | The answer has your `claim`, the `others` already on the issue, and | |
| 113 | + | `blocked_by`: each issue this one waits on, with its claims and pull | |
| 114 | + | requests. | |
| 115 | + | - **Renew** it by claiming again, or give the `session_id` of your session | |
| 116 | + | when you claim, and that session's heartbeats renew it. | |
| 117 | + | - **Release** it with `issue` `release`, or | |
| 118 | + | [`POST /repos/{owner}/{name}/issues/{number}/release`](/reference/api/claims-and-work/release-claim/), | |
| 119 | + | with an optional `reason`. | |
| 120 | + | - **Hand it off** with `issue` `handoff`, or | |
| 121 | + | [`POST /repos/{owner}/{name}/issues/{number}/handoff`](/reference/api/claims-and-work/hand-off-issue/), | |
| 122 | + | with a `note` on where the work stands, for `to` (a username) or anyone. | |
| 123 | + | When whoever it was for claims the issue, their `work.claimed` event | |
| 124 | + | names the hand-off in `took_over`. | |
| 125 | + | ||
| 126 | + | ```sh | |
| 127 | + | curl -X POST https://api.g1t.sh/repos/acme/web/issues/12/claim \ | |
| 128 | + | -H "Authorization: Bearer $G1T_TOKEN" \ | |
| 129 | + | -H "Content-Type: application/json" \ | |
| 130 | + | -d '{"session_id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2", "note": "Taking the argument parsing first."}' | |
| 131 | + | ``` | |
| 132 | + | ||
| 133 | + | | | | | |
| 134 | + | | --- | --- | | |
| 135 | + | | How long a claim lasts | `ttl_minutes`: 30 unless you say, at most 240. | | |
| 136 | + | | When it ends | Released, handed off, its issue closed, or not renewed in time (`expired`). | | |
| 137 | + | | States | `active`, `released`, `expired`, `handed_off`. | | |
| 138 | + | ||
| 139 | + | See who is on what with `issue` `list_claims`, or | |
| 140 | + | [`GET /repos/{owner}/{name}/claims`](/reference/api/claims-and-work/list-claims/): | |
| 141 | + | active claims unless `state` says otherwise, and `issue` for one issue's. | |
| 142 | + | ||
| 143 | + | ## Dependencies | |
| 144 | + | ||
| 145 | + | An issue's `blocked_by` lists the issues of the same repository that have | |
| 146 | + | to land before it. Set it with `issue` `update`, or | |
| 147 | + | [`PATCH /repos/{owner}/{name}/issues/{number}`](/reference/api/issues/update-issue/), | |
| 148 | + | which replaces the whole list: | |
| 149 | + | ||
| 150 | + | ```sh | |
| 151 | + | curl -X PATCH https://api.g1t.sh/repos/acme/web/issues/12 \ | |
| 152 | + | -H "Authorization: Bearer $G1T_TOKEN" \ | |
| 153 | + | -H "Content-Type: application/json" \ | |
| 154 | + | -d '{"blocked_by": [9, 10]}' | |
| 155 | + | ``` | |
| 156 | + | ||
| 157 | + | A list is refused with `422` when a number is not an issue of the | |
| 158 | + | repository, names the issue itself, or would make a cycle (the answer | |
| 159 | + | shows the cycle, such as `#12 → #9 → #12`). Up to 50 issues. Changing it | |
| 160 | + | notes the change in the issue's conversation and needs what editing the | |
| 161 | + | issue does: being its author, or the Triage role. | |
| 162 | + | ||
| 163 | + | g1t says when a dependency moves, as a `dependency.changed` event on the | |
| 164 | + | blocked issue: when its list changes, and when one of its blockers closes | |
| 165 | + | or has a pull request merged into the default branch. `remaining` lists | |
| 166 | + | the blockers still open; when it is empty, the issue is unblocked. | |
| 167 | + | ||
| 168 | + | ## Overlaps | |
| 169 | + | ||
| 170 | + | Opening a pull request, and each push to it, works out which files it | |
| 171 | + | changes. When it starts changing files another open or draft pull request | |
| 172 | + | changes, g1t sends `overlap.detected` once for each of the two, naming the | |
| 173 | + | other and the files. A pair is announced once, and again only after it stopped | |
| 174 | + | overlapping. `get_work` and `pull_request` `get` show current overlaps | |
| 175 | + | whenever you look. | |
| 176 | + | ||
| 177 | + | ## Events | |
| 178 | + | ||
| 179 | + | Coordination is published as events, which [webhooks](/guides/webhooks/) | |
| 180 | + | deliver and the repository's timeline lists. Each carries `number` (the | |
| 181 | + | issue or pull request it is about) and `issue` where there is one. | |
| 182 | + | ||
| 183 | + | | Event | When | | |
| 184 | + | | --- | --- | | |
| 185 | + | | `session.started` | A session starts, or one that ended starts again. Not on a repeated start. | | |
| 186 | + | | `session.ended` | A session ends, or times out after 2 hours without a heartbeat. | | |
| 187 | + | | `work.claimed` | Someone claims an issue. `others` lists the claims already on it; `took_over` names a hand-off it takes. | | |
| 188 | + | | `work.released` | A claim is released, or its issue closed (`reason` `issue_closed`). | | |
| 189 | + | | `work.claim_expired` | A claim was not renewed in time. | | |
| 190 | + | | `work.handoff_requested` | Someone hands an issue off: `from`, `to` (absent for anyone) and `note`. | | |
| 191 | + | | `dependency.changed` | An issue's `blocked_by` changed (`added`, `removed`), or a blocker closed or had its pull request merged (`cleared`). `remaining` lists the blockers still open. | | |
| 192 | + | | `overlap.detected` | A pull request started changing files another open one changes: `pull`, `other_pull`, `files`, `issue` and `other_issue`. | | |
| 193 | + | ||
| 194 | + | A session or claim that went quiet is announced as ended or expired when | |
| 195 | + | the repository's sessions or claims are next read or changed, not at the | |
| 196 | + | minute it lapsed. | |
| 197 | + | ||
| 198 | + | ## Who may do what | |
| 199 | + | ||
| 200 | + | | To | You need | | |
| 201 | + | | --- | --- | | |
| 202 | + | | Read sessions, claims and an issue's context | To be able to read the repository. Public repositories need no token. | | |
| 203 | + | | Start a session, claim an issue | A confirmed email address and a role that may open issues; the repository must not be archived. | | |
| 204 | + | | Update, record in or end a session | To be the person whose token started it. | | |
| 205 | + | | Release a claim or hand it off | To be the person who made it. | | |
| 206 | + | | Set `blocked_by` | To be the issue's author, or the Triage role. | | |
| 207 | + | ||
| 208 | + | With an access token, sessions need `pull_requests:read` or | |
| 209 | + | `pull_requests:write`, and claims and `get_work` need `issues:read` or | |
| 210 | + | `issues:write`, as [scopes](/guides/authentication/#scopes) say. |
| 114 | 114 | | `integration.failed` | A pull request failed in the [merge queue](/guides/merge-queue/#when-an-entry-fails) combined with the ones ahead of it. `pull` (also `number`) is the one that answers for it, `issue` its issue, `combination` the pull requests tested together, `check` the check that failed (`name`, `conclusion`, `url`), `suspects` the pull requests ahead it fails with, `bisected` whether smaller combinations were built to find them, and `other_pulls` the rest of the combination. | | |
| 115 | 115 | | `integration.passed` | A pull request's combined state passed in the merge queue: `pull` and `combination`. | | |
| 116 | 116 | | `decision.recorded` | A [decision record](/guides/why-blame/#decision-records) was kept on an issue or pull request: `decision`, with `number` and `issue`. | | |
| 117 | − | | `session.appended` | A pull request's [session](/guides/why-blame/#sessions) grew: a coding agent recorded entries with `record_session`, or g1t noted a merge queue failure. Busy: choose it only if you need it. | | |
| 117 | + | | `session.appended` | A pull request's [session](/guides/why-blame/#sessions) grew: a coding agent recorded entries with `record_session`, a session on it recorded some, or g1t noted a merge queue failure. Busy: choose it only if you need it. | | |
| 118 | + | | `session.started`, `session.ended` | A [session](/guides/sessions/#sessions) of someone's harness started (not on a repeated start), or ended or timed out. `data.session`, and `data.number` (its pull request, else its issue) and `data.issue`. | | |
| 119 | + | | `work.claimed`, `work.released`, `work.claim_expired`, `work.handoff_requested` | A [claim](/guides/sessions/#claims) on an issue was made, released, expired or handed off. `data.number` and `data.issue` (the issue), `data.claim`; on `work.claimed`, `data.others` (the other active claims) and `data.took_over`; on `work.released`, `data.reason`; on a hand-off, `data.from`, `data.to` and `data.note`. | | |
| 120 | + | | `dependency.changed` | What an issue is [blocked by](/guides/sessions/#dependencies) changed, or a blocker closed or had its pull request merged. `data.number` (the blocked issue), `data.blocked_by`, `data.added`, `data.removed`, `data.cleared`, `data.remaining` (blockers still open) and `data.merged_pull`. | | |
| 121 | + | | `overlap.detected` | A push made a pull request [change files another open one changes](/guides/sessions/#overlaps). Sent once for each of the two: `data.pull`, `data.other_pull`, `data.files`, `data.issue` and `data.other_issue`. | | |
| 118 | 122 | ||
| 119 | 123 | [Managing a repository](/guides/managing-repositories/) says what each of | |
| 120 | 124 | the `repo.` and `branch.` changes does. |
| 9 | 9 | ||
| 10 | 10 | ## Sessions | |
| 11 | 11 | ||
| 12 | − | A session belongs to a pull request. It records how the change was made, | |
| 13 | − | entry by entry, as it happens: | |
| 12 | + | A pull request's session records how the change was made, entry by entry, | |
| 13 | + | as it happens. Entries come from `record_session` on the pull request, and | |
| 14 | + | from any [harness session](/guides/sessions/#sessions) attached to it: | |
| 14 | 15 | ||
| 15 | 16 | | Kind | What it holds | | |
| 16 | 17 | | --- | --- | |
| 741 | 741 | g1t status [OPTIONS] | |
| 742 | 742 | ``` | |
| 743 | 743 | ||
| 744 | − | Calls [`search`](/reference/api/search/search/), [`list_notifications`](/reference/api/notifications/list-notifications/), [`get_pull_request`](/reference/api/pull-requests/get-pull-request/), [`list_sessions`](/reference/api/sessions/list-sessions/), [`list_claims`](/reference/api/issues/list-claims/). | |
| 744 | + | Calls [`search`](/reference/api/search/search/), [`list_notifications`](/reference/api/notifications/list-notifications/), [`get_pull_request`](/reference/api/pull-requests/get-pull-request/), [`list_sessions`](/reference/api/sessions/list-sessions/), [`list_claims`](/reference/api/claims-and-work/list-claims/). | |
| 745 | 745 | ||
| 746 | 746 | ## g1t session | |
| 747 | 747 | ||
| ⋯ | |||
| 876 | 876 | | --- | --- | | |
| 877 | 877 | | `<NUMBER>` | The issue's number. | | |
| 878 | 878 | ||
| 879 | − | Calls [`get_work`](/reference/api/issues/get-work/). | |
| 879 | + | Calls [`get_work`](/reference/api/claims-and-work/get-work/). | |
| 880 | 880 | ||
| 881 | 881 | ### g1t work claim | |
| 882 | 882 | ||
| ⋯ | |||
| 892 | 892 | | `-n, --note <NOTE>` | What you are doing about it. | | |
| 893 | 893 | | `--ttl <DURATION>` | How long the claim lasts unless renewed: 30m, 2h (at most 4h). | | |
| 894 | 894 | ||
| 895 | − | Calls [`claim_issue`](/reference/api/issues/claim-issue/). | |
| 895 | + | Calls [`claim_issue`](/reference/api/claims-and-work/claim-issue/). | |
| 896 | 896 | ||
| 897 | 897 | ### g1t work release | |
| 898 | 898 | ||
| ⋯ | |||
| 907 | 907 | | `<NUMBER>` | | | |
| 908 | 908 | | `-r, --reason <REASON>` | Why: done, blocked, abandoned... | | |
| 909 | 909 | ||
| 910 | − | Calls [`release_claim`](/reference/api/issues/release-claim/). | |
| 910 | + | Calls [`release_claim`](/reference/api/claims-and-work/release-claim/). | |
| 911 | 911 | ||
| 912 | 912 | ### g1t work handoff | |
| 913 | 913 | ||
| ⋯ | |||
| 923 | 923 | | `--to <LOGIN>` | Who takes it over; anyone when left out. | | |
| 924 | 924 | | `-n, --note <NOTE>` | Where it stands and what is left, for whoever takes it. | | |
| 925 | 925 | ||
| 926 | − | Calls [`hand_off_issue`](/reference/api/issues/hand-off-issue/). | |
| 926 | + | Calls [`hand_off_issue`](/reference/api/claims-and-work/hand-off-issue/). | |
| 927 | 927 | ||
| 928 | 928 | ### g1t work list | |
| 929 | 929 | ||
| ⋯ | |||
| 933 | 933 | g1t work list [OPTIONS] | |
| 934 | 934 | ``` | |
| 935 | 935 | ||
| 936 | − | Calls [`list_claims`](/reference/api/issues/list-claims/), [`list_sessions`](/reference/api/sessions/list-sessions/). | |
| 936 | + | Calls [`list_claims`](/reference/api/claims-and-work/list-claims/), [`list_sessions`](/reference/api/sessions/list-sessions/). | |
| 937 | 937 | ||
| 938 | 938 | ### g1t work block | |
| 939 | 939 | ||
| ⋯ | |||
| 1004 | 1004 | | `<TARGET>` | A file, or a line of it as PATH:LINE. | | |
| 1005 | 1005 | | `--ref <REF>` | The branch, tag or commit to read it at; the default branch when left out. | | |
| 1006 | 1006 | ||
| 1007 | − | Calls [`why`](/reference/api/repositories/why/). | |
| 1007 | + | Calls [`why`](/reference/api/decisions-and-why/why/). | |
| 1008 | 1008 | ||
| 1009 | 1009 | ## g1t api | |
| 1010 | 1010 | ||
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…