Skip to content

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

386 linesCodeBlameRaw
1//! Coordinating work between agents and people
2//! (`g1t_contracts::work::coordination`): sessions of their own harnesses
3//! on a repository (sessions.rs), advisory claims on work items
4//! (claims.rs), what a work item is blocked by (dependencies.rs), pull
5//! requests that start changing the same files (overlaps.rs), and the one
6//! call that gathers it all for a work item (context.rs).
7//!
8//! Nothing here is a lock and nothing waits on a harness: sessions and
9//! claims that went quiet are found when the repository's coordination is
10//! next touched (`sweep`), and announced then as ended or expired.
11
12pub(crate) mod updates;
13mod claims;
14mod context;
15mod dependencies;
16mod links;
17mod overlaps;
18mod sessions;
19pub(crate) mod shown;
20pub(crate) mod titles;
21#[cfg(test)]
22mod tests;
23
24use g1t_contracts::credentials::Principal;
25use g1t_contracts::repos::{Repo, RepoPath};
26use g1t_contracts::time::{parse_rfc3339, rfc3339};
27use g1t_contracts::work::*;
28use g1t_contracts::{FailureCode, Outcome, User, Viewer};
29use g1t_kit::now_ms;
30use serde::Deserialize;
31use worker::Result;
32
33use crate::Work;
34
35/// Unwraps an `Outcome`, returning its failure from the enclosing method.
36macro_rules! unwrap_or_fail {
37 ($outcome:expr) => {
38 match $outcome {
39 g1t_contracts::Outcome::Ok(value) => value,
40 g1t_contracts::Outcome::Fail(failure) => return Ok(g1t_contracts::Outcome::Fail(failure)),
41 }
42 };
43}
44pub(crate) use unwrap_or_fail;
45
46/// A session's columns, with its work item's number beside its id.
47const SESSION_COLUMNS: &str = concat!(
48 "id, repo_id, item_id,
49 ",
50 crate::items::sql::item_number!("sessions.item_id"),
51 " AS item,
52 pull, branch, actor_id, actor_name, harness, harness_session_id,
53 title, state, head_commit, started_at, last_seen_at, ended_at, end_reason,
54 parent_id, agent_type, task, prompt, result, model, title_source, title_settled_at"
55);
56
57/// A claim's columns, with its work item's workspace and number.
58const CLAIM_COLUMNS: &str = concat!(
59 "id, item_id, repo_id,
60 ",
61 crate::items::sql::item_workspace!("claims.item_id"),
62 " AS workspace,
63 ",
64 crate::items::sql::item_number!("claims.item_id"),
65 " AS item,
66 session_id, actor_id, actor_name, harness, note, state,
67 ttl_minutes, created_at, expires_at, ended_at, end_reason, handoff_to"
68);
69
70#[derive(Clone, Deserialize)]
71pub(crate) struct SessionRow {
72 id: String,
73 repo_id: String,
74 #[serde(default)]
75 item_id: Option<String>,
76 #[serde(default)]
77 item: Option<u32>,
78 pull: Option<u32>,
79 branch: Option<String>,
80 actor_id: String,
81 actor_name: String,
82 harness: String,
83 harness_session_id: Option<String>,
84 title: Option<String>,
85 state: String,
86 head_commit: Option<String>,
87 started_at: String,
88 last_seen_at: String,
89 ended_at: Option<String>,
90 end_reason: Option<String>,
91 #[serde(default)]
92 parent_id: Option<String>,
93 #[serde(default)]
94 agent_type: Option<String>,
95 #[serde(default)]
96 task: Option<String>,
97 #[serde(default)]
98 prompt: Option<String>,
99 #[serde(default)]
100 result: Option<String>,
101 #[serde(default)]
102 model: Option<String>,
103 #[serde(default)]
104 title_source: Option<String>,
105 #[serde(default)]
106 title_settled_at: Option<String>,
107}
108
109impl SessionRow {
110 /// The session as it reads at `now`.
111 pub(crate) fn at(self, now: u64) -> Session {
112 let last_seen = parse_rfc3339(&self.last_seen_at).unwrap_or(now);
113 Session {
114 state: SessionState::at(self.state == "ended", last_seen, now),
115 id: self.id,
116 repo_id: self.repo_id,
117 item: self.item,
118 pull: self.pull,
119 branch: self.branch,
120 actor: Principal { id: self.actor_id, username: self.actor_name },
121 harness: Harness::parse(&self.harness).unwrap_or(Harness::Other),
122 harness_session_id: self.harness_session_id,
123 title: self.title,
124 head_commit: self.head_commit,
125 started_at: self.started_at,
126 last_seen_at: self.last_seen_at,
127 ended_at: self.ended_at,
128 end_reason: self.end_reason,
129 parent_id: self.parent_id,
130 agent_type: self.agent_type,
131 task: self.task,
132 prompt: self.prompt,
133 result: self.result,
134 model: self.model,
135 subagents: 0,
136 first_prompt: None,
137 title_source: self.title_source.as_deref().and_then(TitleSource::parse),
138 title_settled_at: self.title_settled_at,
139 }
140 }
141}
142
143#[derive(Deserialize)]
144pub(crate) struct ClaimRow {
145 id: String,
146 pub(crate) item_id: String,
147 #[serde(default)]
148 repo_id: Option<String>,
149 #[serde(default)]
150 workspace: Option<String>,
151 #[serde(default)]
152 item: Option<u32>,
153 session_id: Option<String>,
154 actor_id: String,
155 actor_name: String,
156 harness: Option<String>,
157 note: Option<String>,
158 state: String,
159 #[allow(dead_code)]
160 ttl_minutes: u32,
161 created_at: String,
162 expires_at: String,
163 ended_at: Option<String>,
164 end_reason: Option<String>,
165 handoff_to: Option<String>,
166}
167
168impl From<ClaimRow> for Claim {
169 fn from(row: ClaimRow) -> Self {
170 Claim {
171 id: row.id,
172 repo_id: row.repo_id,
173 workspace: row.workspace.unwrap_or_default(),
174 item: row.item.unwrap_or_default(),
175 session_id: row.session_id,
176 actor: Principal { id: row.actor_id, username: row.actor_name },
177 harness: row.harness.as_deref().and_then(Harness::parse),
178 note: row.note,
179 state: ClaimState::parse(&row.state).unwrap_or(ClaimState::Expired),
180 created_at: row.created_at,
181 expires_at: row.expires_at,
182 ended_at: row.ended_at,
183 end_reason: row.end_reason,
184 handoff_to: row.handoff_to,
185 }
186 }
187}
188
189/// Text as given, trimmed and cut to `limit` characters; none when empty.
190pub(crate) fn tidy(text: Option<&str>, limit: usize) -> Option<String> {
191 let text = text?.trim();
192 (!text.is_empty()).then(|| text.chars().take(limit).collect())
193}
194
195/// The most sessions, and claims, one sweep ends; the rest wait for the next.
196const SWEEP_LIMIT: u32 = 50;
197
198/// Ends a repository's (`?1`) session `?3` if last seen at or before `?2`,
199/// as `timed_out`, as of when it was last seen; its title is settled a
200/// last time (titles.rs).
201pub(crate) fn time_out_sessions_sql() -> String {
202 format!(
203 "UPDATE sessions SET state = 'ended', ended_at = last_seen_at, end_reason = 'timed_out', {final_due}
204 WHERE repo_id = ?1 AND state = 'active' AND last_seen_at <= ?2 AND id = ?3
205 RETURNING {SESSION_COLUMNS}",
206 final_due = titles::final_due("last_seen_at")
207 )
208}
209
210/// Expires active claim `?3` if due at or before `?2` (`?1` unused, so
211/// the binds are the sessions').
212pub(crate) fn expire_claims_sql() -> String {
213 format!(
214 "UPDATE claims SET state = 'expired', ended_at = expires_at, end_reason = 'expired'
215 WHERE ?1 IS NOT NULL AND state = 'active' AND expires_at <= ?2 AND id = ?3
216 RETURNING {CLAIM_COLUMNS}"
217 )
218}
219
220/// RFC 3339 for `minutes` after `now`.
221pub(crate) fn after_minutes(now: u64, minutes: u32) -> String {
222 rfc3339(now + u64::from(minutes) * 60_000)
223}
224
225impl Work {
226 /// The repository as `viewer` sees it, for reading its coordination.
227 pub(crate) async fn coordination_repo(&self, path: &RepoPath, viewer: &Viewer) -> Result<Outcome<Repo>> {
228 let repo = unwrap_or_fail!(self.repo(path, viewer).await?);
229 self.sweep(&repo.id).await?;
230 Ok(Outcome::Ok(repo))
231 }
232
233 /// The repository, if `actor` may take part in it: a confirmed email
234 /// address, a repository that is not archived, and a role that may
235 /// open work items and pull requests.
236 pub(crate) async fn participating_repo(&self, path: &RepoPath, actor: &User) -> Result<Outcome<Repo>> {
237 if !actor.verified {
238 return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED));
239 }
240 let repo = unwrap_or_fail!(self.repo(path, &Some(actor.clone())).await?);
241 unwrap_or_fail!(crate::retired::writable(&repo));
242 unwrap_or_fail!(crate::allowed(Some(actor), &repo, g1t_contracts::access::Capability::Participate));
243 self.sweep(&repo.id).await?;
244 Ok(Outcome::Ok(repo))
245 }
246
247 /// Ends the repository's sessions that have gone 30 minutes without a
248 /// heartbeat (`timed_out`) and expires the claims made from it or on
249 /// work items linked to it past their time, and announces each. Run
250 /// before the repository's coordination is read or changed, so that
251 /// nobody sees a stale one as live.
252 pub(crate) async fn sweep(&self, repo_id: &str) -> Result<()> {
253 let now = now_ms();
254 let quiet_since = rfc3339(now.saturating_sub(SESSION_TIMEOUT_MS));
255 let at = rfc3339(now);
256 // Read first: most of the time nothing is due, and nothing is written.
257 let due = self
258 .db
259 .batch(vec![
260 self.db
261 .prepare(format!(
262 "SELECT {SESSION_COLUMNS} FROM sessions
263 WHERE repo_id = ?1 AND state = 'active' AND last_seen_at <= ?2 LIMIT ?3"
264 ))
265 .bind(&[repo_id.into(), quiet_since.as_str().into(), SWEEP_LIMIT.into()])?,
266 self.db
267 .prepare(format!(
268 "SELECT {CLAIM_COLUMNS} FROM claims
269 WHERE state = 'active' AND expires_at <= ?2 AND (repo_id = ?1 OR item_id IN {linked})
270 LIMIT ?3",
271 linked = crate::items::sql::items_linked_to!("?1"),
272 ))
273 .bind(&[repo_id.into(), at.as_str().into(), SWEEP_LIMIT.into()])?,
274 ])
275 .await?;
276 let mut due = due.into_iter();
277 let quiet: Vec<SessionRow> = match due.next() {
278 Some(result) => result.results()?,
279 None => Vec::new(),
280 };
281 let lapsed: Vec<ClaimRow> = match due.next() {
282 Some(result) => result.results()?,
283 None => Vec::new(),
284 };
285 if quiet.is_empty() && lapsed.is_empty() {
286 return Ok(());
287 }
288 // Each one's end and its updates are one change: a sweep running at
289 // the same moment ends it, and delivers it, once.
290 let mut statements = Vec::new();
291 let mut sessions = Vec::new();
292 for row in quiet {
293 statements.push(
294 self.db
295 .prepare(time_out_sessions_sql())
296 .bind(&[repo_id.into(), quiet_since.as_str().into(), row.id.as_str().into()])?,
297 );
298 let index = statements.len() - 1;
299 let ended_at = row.last_seen_at.clone();
300 let expected = SessionRow {
301 state: "ended".to_owned(),
302 ended_at: Some(ended_at.clone()),
303 end_reason: Some("timed_out".to_owned()),
304 ..row
305 }
306 .at(now);
307 let delivery = self.delivery(
308 "session.ended",
309 repo_id,
310 serde_json::to_value(SessionEvent::of(&expected))?,
311 &format!("{}:ended:{ended_at}", expected.id),
312 "EXISTS (SELECT 1 FROM sessions WHERE id = ?7 AND state = 'ended' AND ended_at = ?8)",
313 vec![expected.id.as_str().into(), ended_at.as_str().into()],
314 )?;
315 sessions.push((index, updates::push_delivery(&mut statements, delivery)));
316 }
317 let mut claims = Vec::new();
318 for row in lapsed {
319 statements.push(
320 self.db.prepare(expire_claims_sql()).bind(&[repo_id.into(), at.as_str().into(), row.id.as_str().into()])?,
321 );
322 let index = statements.len() - 1;
323 let held = Claim::from(row);
324 let expected = Claim {
325 state: ClaimState::Expired,
326 ended_at: Some(held.expires_at.clone()),
327 end_reason: Some("expired".to_owned()),
328 ..held
329 };
330 let delivery = self.delivery(
331 "work.claim_expired",
332 repo_id,
333 serde_json::to_value(ClaimEvent::of(&expected, &[repo_id.to_owned()]))?,
334 &format!("{}:{}", expected.id, ClaimState::Expired.as_str()),
335 "EXISTS (SELECT 1 FROM claims WHERE id = ?7 AND state = 'expired')",
336 vec![expected.id.as_str().into()],
337 )?;
338 claims.push((index, updates::push_delivery(&mut statements, delivery)));
339 }
340 let results = self.db.batch(statements).await?;
341 let returned = |index: usize| results.get(index);
342 for (index, sent) in sessions {
343 let Some(row) = returned(index).map(|result| result.results::<SessionRow>()).transpose()?.and_then(|rows| rows.into_iter().next())
344 else {
345 continue;
346 };
347 let session = row.at(now);
348 self.delivered(sent, "session.ended", repo_id, None, serde_json::to_value(SessionEvent::of(&session))?).await?;
349 }
350 for (index, sent) in claims {
351 let Some(row) = returned(index).map(|result| result.results::<ClaimRow>()).transpose()?.and_then(|rows| rows.into_iter().next())
352 else {
353 continue;
354 };
355 let claim = Claim::from(row);
356 let data = serde_json::to_value(ClaimEvent::of(&claim, &[repo_id.to_owned()]))?;
357 self.delivered(sent, "work.claim_expired", repo_id, None, data).await?;
358 }
359 Ok(())
360 }
361
362 pub(crate) async fn session_row(&self, repo_id: &str, id: &str) -> Result<Option<SessionRow>> {
363 self.db
364 .prepare(format!("SELECT {SESSION_COLUMNS} FROM sessions WHERE repo_id = ? AND id = ?"))
365 .bind(&[repo_id.into(), id.into()])?
366 .first::<SessionRow>(None)
367 .await
368 }
369
370 /// Active claims on a work item, oldest first.
371 pub(crate) async fn active_claims(&self, item_id: &str) -> Result<Vec<Claim>> {
372 Ok(self
373 .db
374 .prepare(format!(
375 "SELECT {CLAIM_COLUMNS} FROM claims
376 WHERE item_id = ? AND state = 'active' ORDER BY created_at, id"
377 ))
378 .bind(&[item_id.into()])?
379 .all()
380 .await?
381 .results::<ClaimRow>()?
382 .into_iter()
383 .map(Claim::from)
384 .collect())
385 }
386}