Skip to content

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

1,025 linesCodeBlameRaw
1//! Sessions: one run of someone's own harness on a repository.
2//!
3//! Starting one is idempotent on the harness's own session id, so a hook
4//! that fires twice, or a harness that reconnects, gets the same session
5//! back. Every update, and every batch of entries, is a heartbeat: it keeps
6//! the session active, brings back one that timed out, and renews the
7//! claims made from it. Only the person whose token started a session
8//! changes it; anyone with write access may also end it, for one left
9//! behind (recorded as ended by hand by them).
10//!
11//! A subagent is a session of its own with a parent (`parent_id`): a
12//! Claude Code subagent, a Managed Agents thread. It starts on what its
13//! parent is on unless it says otherwise, keeps the task, prompt and
14//! result its parent gave and got (cut short), and once ended stays ended
15//! even if a late hook starts it again: a subagent never comes back. Its
16//! heartbeats keep its parent seen, and ending a session ends the
17//! subagents it left going.
18
19use g1t_contracts::events::SessionAppended;
20use g1t_contracts::repos::Repo;
21use g1t_contracts::time::rfc3339;
22use g1t_contracts::work::*;
23use g1t_contracts::{FailureCode, Outcome, User, new_id};
24use g1t_kit::now_ms;
25use serde::Deserialize;
26use worker::Result;
27use worker::wasm_bindgen::JsValue;
28
29use super::links::{PULL_SESSIONS, PullSessionRow, count_subagents, end_tree_sql, pull_sessions_of, tree_sql};
30
31/// The most pull requests one `pull_sessions` asks about.
32const MAX_PULL_SESSIONS: usize = 100;
33use super::updates::push_delivery;
34use super::{SESSION_COLUMNS, SessionRow, after_minutes, shown, tidy, unwrap_or_fail};
35use crate::Work;
36use crate::rows::NumberRow;
37
38/// The most entries one look at a session returns.
39const ENTRY_PAGE: u32 = 500;
40/// The most sessions one list returns.
41const LIST_LIMIT: u32 = 100;
42const MAX_TITLE_CHARS: usize = 200;
43const MAX_BRANCH_CHARS: usize = 255;
44const MAX_ID_CHARS: usize = 200;
45const MAX_COMMIT_CHARS: usize = 64;
46const MAX_AGENT_CHARS: usize = 100;
47const MAX_MODEL_CHARS: usize = 100;
48/// How long one prompt entry may be before its `#N` go unread.
49const MAX_REF_TEXT: usize = 8_000;
50
51fn not_found<T>() -> Outcome<T> {
52 Outcome::fail(FailureCode::NotFound, "Session not found.")
53}
54
55fn text_or_null(value: &Option<String>) -> JsValue {
56 value.as_deref().map_or(JsValue::NULL, JsValue::from)
57}
58
59fn number_or_null(value: Option<u32>) -> JsValue {
60 value.map_or(JsValue::NULL, JsValue::from)
61}
62
63/// Long text as kept: trimmed, cut to `limit` characters with an ellipsis.
64fn clip(text: Option<&str>, limit: usize) -> Option<String> {
65 let text = text?.trim();
66 if text.is_empty() {
67 return None;
68 }
69 if text.chars().count() <= limit {
70 return Some(text.to_owned());
71 }
72 let cut: String = text.chars().take(limit.saturating_sub(1)).collect();
73 Some(format!("{}…", cut.trim_end()))
74}
75
76/// What a session's heartbeat may change, tidied.
77#[derive(Default)]
78struct Fields {
79 /// Its work item, by id and number.
80 item: Option<(String, u32)>,
81 pull: Option<u32>,
82 branch: Option<String>,
83 head_commit: Option<String>,
84 title: Option<String>,
85 agent_type: Option<String>,
86 task: Option<String>,
87 prompt: Option<String>,
88 model: Option<String>,
89 result: Option<String>,
90}
91
92impl Fields {
93 fn binds(&self) -> Vec<JsValue> {
94 vec![
95 self.item.as_ref().map_or(JsValue::NULL, |(id, _)| JsValue::from(id.as_str())),
96 number_or_null(self.pull),
97 text_or_null(&self.branch),
98 text_or_null(&self.head_commit),
99 text_or_null(&self.title),
100 text_or_null(&self.agent_type),
101 text_or_null(&self.task),
102 text_or_null(&self.prompt),
103 text_or_null(&self.model),
104 text_or_null(&self.result),
105 ]
106 }
107
108 /// The text whose `#N` tie the session to work items and pull requests.
109 fn named(&self) -> String {
110 [&self.title, &self.task, &self.prompt].into_iter().flatten().cloned().collect::<Vec<_>>().join("\n")
111 }
112}
113
114#[derive(Deserialize)]
115struct RenewRow {
116 id: String,
117 ttl_minutes: u32,
118 expires_at: String,
119}
120
121/// Starts a session; nothing when one with the same harness session id
122/// was started already.
123pub(crate) fn insert_session_sql() -> String {
124 format!(
125 "INSERT INTO sessions
126 (id, repo_id, item_id, pull, branch, actor_id, actor_name, harness, harness_session_id,
127 title, state, head_commit, started_at, last_seen_at, parent_id, agent_type, task, prompt, model)
128 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'active', ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
129 ON CONFLICT (repo_id, actor_id, harness, harness_session_id)
130 WHERE harness_session_id IS NOT NULL DO NOTHING
131 RETURNING {SESSION_COLUMNS}"
132 )
133}
134
135/// A person's session by its harness's id for it.
136pub(crate) fn harness_session_sql() -> String {
137 format!(
138 "SELECT {SESSION_COLUMNS} FROM sessions
139 WHERE repo_id = ?1 AND actor_id = ?2 AND harness = ?3 AND harness_session_id = ?4"
140 )
141}
142
143/// The fields `?1`–`?10` of [`Fields::binds`], each kept when not given.
144const SET_FIELDS: &str = "item_id = COALESCE(?1, item_id), pull = COALESCE(?2, pull), branch = COALESCE(?3, branch),
145 head_commit = COALESCE(?4, head_commit), title = COALESCE(?5, title),
146 agent_type = COALESCE(?6, agent_type), task = COALESCE(?7, task), prompt = COALESCE(?8, prompt),
147 model = COALESCE(?9, model), result = COALESCE(?10, result)";
148
149/// A heartbeat: seen now (`?11`), active again, with whichever fields are
150/// given, of session `?12`.
151pub(crate) fn resume_sql() -> String {
152 format!(
153 "UPDATE sessions
154 SET {SET_FIELDS},
155 last_seen_at = ?11, state = 'active', ended_at = NULL, end_reason = NULL
156 WHERE id = ?12
157 RETURNING {SESSION_COLUMNS}"
158 )
159}
160
161/// The fields of session `?12`, changed without bringing it back: for a
162/// subagent's late hooks (`?11` unused, so the binds are the same).
163pub(crate) fn refresh_sql() -> String {
164 format!(
165 "UPDATE sessions SET {SET_FIELDS}
166 WHERE id = ?12 AND ?11 IS NOT NULL
167 RETURNING {SESSION_COLUMNS}"
168 )
169}
170
171/// A subagent's heartbeat keeps its parent `?2` seen at `?1`.
172pub(crate) const SEE_PARENT: &str = "UPDATE sessions SET last_seen_at = ?1 WHERE id = ?2 AND state = 'active' AND last_seen_at < ?1";
173
174/// Records one entry in session `?7`, and in pull request `?1` when it
175/// is on one. Each takes the next number of the session, and of its pull
176/// request, itself, so two batches at once cannot collide; an entry whose
177/// key (`?8`) the session has recorded already is skipped.
178pub(crate) const INSERT_ENTRY: &str = "INSERT INTO session_entries
179 (pull_id, seq, kind, text, tool, \"commit\", at, session_id, session_seq, key)
180 SELECT ?1,
181 CASE WHEN ?1 IS NULL
182 THEN (SELECT COALESCE(MAX(session_seq), 0) + 1 FROM session_entries WHERE session_id = ?7)
183 ELSE (SELECT COALESCE(MAX(seq), 0) + 1 FROM session_entries WHERE pull_id = ?1) END,
184 ?2, ?3, ?4, ?5, ?6, ?7,
185 (SELECT COALESCE(MAX(session_seq), 0) + 1 FROM session_entries WHERE session_id = ?7),
186 ?8
187 WHERE true
188 ON CONFLICT (session_id, key) WHERE session_id IS NOT NULL AND key IS NOT NULL DO NOTHING
189 RETURNING session_seq AS n";
190
191/// Entries of session `?3` recorded before it named its pull request
192/// `?1` join it, numbered after its last (`?2`), in their order.
193pub(crate) const MOVE_ENTRIES: &str = "UPDATE session_entries SET pull_id = ?1, seq = ?2 + session_seq
194 WHERE session_id = ?3 AND pull_id IS NULL";
195
196/// Renews active claim `?2` to `?1`.
197pub(crate) const RENEW_CLAIM: &str = "UPDATE claims SET expires_at = ?1 WHERE id = ?2 AND state = 'active'";
198
199/// Marks session `?2` as announced at `?1` when it was last announced at
200/// or before `?3` (or never): a row back means it is time to announce.
201pub(crate) const MARK_PROGRESS: &str = "UPDATE sessions SET progressed_at = ?1
202 WHERE id = ?2 AND (progressed_at IS NULL OR progressed_at <= ?3) RETURNING progressed_at";
203
204/// When a claim of `minutes` renewed at `now` lapses, if that is later
205/// than it does already (`expires_at`): a heartbeat never shortens one.
206pub(crate) fn renewal(now: u64, minutes: u32, expires_at: &str) -> Option<String> {
207 let renewed = after_minutes(now, minutes);
208 (renewed.as_str() > expires_at).then_some(renewed)
209}
210
211/// Whether a session is a subagent that ended on purpose: it stays ended.
212fn finished_subagent(row: &SessionRow) -> bool {
213 row.parent_id.is_some() && row.state == "ended" && !revives(row.end_reason.as_deref())
214}
215
216impl Work {
217 /// The work item and pull request a session names, checked: a work
218 /// item of the repository's workspace, and a pull request of the
219 /// repository. A pull request for a work item names that item when no
220 /// item was given.
221 async fn session_subjects(&self, repo: &Repo, item: Option<u32>, pull: Option<u32>) -> Result<Outcome<(Option<(String, u32)>, Option<u32>)>> {
222 let workspace = repo.namespace.to_lowercase();
223 let mut found_item = None;
224 if let Some(number) = item.filter(|number| *number > 0) {
225 match self.item(&workspace, number).await? {
226 Some(held) => found_item = Some((held.item.id, number)),
227 None => return Ok(Outcome::fail(FailureCode::Invalid, format!("#{number} is not a work item in {workspace}."))),
228 }
229 }
230 let pull = pull.filter(|number| *number > 0);
231 if let Some(number) = pull {
232 match self.pull(&repo.id, number).await? {
233 Some(found) => {
234 if found_item.is_none()
235 && let (Some(id), Some(number)) = (self.pull_item_id(&found.id).await?, found.item)
236 {
237 found_item = Some((id, number));
238 }
239 }
240 None => {
241 return Ok(Outcome::fail(FailureCode::Invalid, format!("#{number} is not a pull request in this repository.")));
242 }
243 }
244 }
245 Ok(Outcome::Ok((found_item, pull)))
246 }
247
248 /// Ties a session to what it said it is on, and what its text names.
249 async fn link_fields(&self, session: &Session, fields: &Fields) -> Result<()> {
250 if let Some((id, _)) = &fields.item {
251 self.link_item(&session.id, &session.repo_id, id, LinkVia::Attached).await;
252 }
253 if let Some(number) = fields.pull {
254 self.link_pull(&session.id, &session.repo_id, number, LinkVia::Attached).await;
255 }
256 if let Some(branch) = &fields.branch {
257 self.link_branch(&session.id, &session.repo_id, branch).await?;
258 }
259 let named = fields.named();
260 if !named.is_empty() {
261 self.link_refs(&session.id, &session.repo_id, &named).await?;
262 }
263 Ok(())
264 }
265
266 pub(crate) async fn start_session(&self, a: StartSessionArgs) -> Result<Outcome<Session>> {
267 let repo = unwrap_or_fail!(self.participating_repo(&a.repo, &a.actor).await?);
268 // A subagent's parent: one of the actor's sessions here.
269 let parent = match a.parent_id.as_deref().map(str::trim).filter(|id| !id.is_empty()) {
270 Some(id) => match self.session_row(&repo.id, id).await? {
271 Some(row) if row.actor_id == a.actor.id => Some(row),
272 _ => return Ok(Outcome::fail(FailureCode::Invalid, "parent_id is not one of your sessions in this repository.")),
273 },
274 None => None,
275 };
276 let (item, pull) = unwrap_or_fail!(self.session_subjects(&repo, a.item, a.pull).await?);
277 let mut fields = Fields {
278 item,
279 pull,
280 branch: tidy(a.branch.as_deref(), MAX_BRANCH_CHARS),
281 head_commit: tidy(a.head_commit.as_deref(), MAX_COMMIT_CHARS),
282 title: tidy(a.title.as_deref().map(shown::clean).as_deref(), MAX_TITLE_CHARS),
283 agent_type: tidy(a.agent_type.as_deref(), MAX_AGENT_CHARS),
284 task: clip(a.task.as_deref().map(shown::clean).as_deref(), MAX_TASK_CHARS),
285 prompt: clip(a.prompt.as_deref().map(shown::clean).as_deref(), MAX_PROMPT_CHARS),
286 model: tidy(a.model.as_deref(), MAX_MODEL_CHARS),
287 result: None,
288 };
289 let harness_session_id = tidy(a.harness_session_id.as_deref(), MAX_ID_CHARS);
290 let now = now_ms();
291 let at = rfc3339(now);
292 // The same harness session again: the one already started.
293 if let Some(harness_id) = &harness_session_id
294 && let Some(row) = self.harness_session(&repo.id, &a.actor, a.harness, harness_id).await?
295 {
296 return self.resume(&repo, row, &a.actor, fields).await;
297 }
298 // A subagent works on what its parent does unless it says.
299 if let Some(parent) = &parent {
300 fields.item = fields.item.clone().or(parent.item_id.clone().zip(parent.item));
301 fields.pull = fields.pull.or(parent.pull);
302 fields.branch = fields.branch.clone().or(parent.branch.clone());
303 fields.head_commit = fields.head_commit.clone().or(parent.head_commit.clone());
304 }
305 let parent_id = parent.as_ref().map(|parent| parent.id.clone());
306 let id = new_id("ses", now);
307 // The session as the insert makes it, announced with it.
308 let expected = SessionRow {
309 id: id.clone(),
310 repo_id: repo.id.clone(),
311 item_id: fields.item.as_ref().map(|(id, _)| id.clone()),
312 item: fields.item.as_ref().map(|(_, number)| *number),
313 pull: fields.pull,
314 branch: fields.branch.clone(),
315 actor_id: a.actor.id.clone(),
316 actor_name: a.actor.username.clone(),
317 harness: a.harness.as_str().to_owned(),
318 harness_session_id: harness_session_id.clone(),
319 title: fields.title.clone(),
320 state: "active".to_owned(),
321 head_commit: fields.head_commit.clone(),
322 started_at: at.clone(),
323 last_seen_at: at.clone(),
324 ended_at: None,
325 end_reason: None,
326 parent_id: parent_id.clone(),
327 agent_type: fields.agent_type.clone(),
328 task: fields.task.clone(),
329 prompt: fields.prompt.clone(),
330 result: None,
331 model: fields.model.clone(),
332 }
333 .at(now);
334 let started = |session: &Session| g1t_contracts::events::marked(SessionEvent::of(session), Some(&a.actor));
335 let mut statements = vec![self
336 .db
337 .prepare(insert_session_sql())
338 .bind(&[
339 id.as_str().into(),
340 repo.id.as_str().into(),
341 fields.item.as_ref().map_or(JsValue::NULL, |(id, _)| JsValue::from(id.as_str())),
342 number_or_null(fields.pull),
343 text_or_null(&fields.branch),
344 a.actor.id.as_str().into(),
345 a.actor.username.as_str().into(),
346 a.harness.as_str().into(),
347 text_or_null(&harness_session_id),
348 text_or_null(&fields.title),
349 text_or_null(&fields.head_commit),
350 at.as_str().into(),
351 at.as_str().into(),
352 text_or_null(&parent_id),
353 text_or_null(&fields.agent_type),
354 text_or_null(&fields.task),
355 text_or_null(&fields.prompt),
356 text_or_null(&fields.model),
357 ])?];
358 // Started at the same moment by another call: none, and no update.
359 let sent = push_delivery(
360 &mut statements,
361 self.delivery(
362 "session.started",
363 &repo.id,
364 started(&expected),
365 &format!("{id}:started"),
366 "EXISTS (SELECT 1 FROM sessions WHERE id = ?7)",
367 vec![id.as_str().into()],
368 )?,
369 );
370 let inserted = match self.db.batch(statements).await?.into_iter().next() {
371 Some(result) => result.results::<SessionRow>()?.into_iter().next(),
372 None => None,
373 };
374 let Some(row) = inserted else {
375 // Started at the same moment by another call: that one.
376 let Some(harness_id) = &harness_session_id else {
377 return Ok(not_found());
378 };
379 return match self.harness_session(&repo.id, &a.actor, a.harness, harness_id).await? {
380 Some(row) => Ok(Outcome::Ok(row.at(now))),
381 None => Ok(not_found()),
382 };
383 };
384 let session = row.at(now);
385 self.link_fields(&session, &fields).await?;
386 if let Some(parent) = &parent_id {
387 self.db.prepare(SEE_PARENT).bind(&[at.as_str().into(), parent.as_str().into()])?.run().await?;
388 }
389 self.delivered(sent, "session.started", &repo.id, Some(a.actor.id.clone()), started(&session)).await?;
390 Ok(Outcome::Ok(session))
391 }
392
393 async fn harness_session(&self, repo_id: &str, actor: &User, harness: Harness, harness_id: &str) -> Result<Option<SessionRow>> {
394 self.db
395 .prepare(harness_session_sql())
396 .bind(&[repo_id.into(), actor.id.as_str().into(), harness.as_str().into(), harness_id.into()])?
397 .first::<SessionRow>(None)
398 .await
399 }
400
401 /// A heartbeat, with whatever `fields` change: the session is seen now,
402 /// brought back if it had ended (announced as started again), its
403 /// claims renewed, and entries recorded before it named its pull
404 /// request moved onto it. A subagent that finished only takes the
405 /// fields, and stays ended.
406 async fn resume(&self, repo: &Repo, row: SessionRow, actor: &User, fields: Fields) -> Result<Outcome<Session>> {
407 let now = now_ms();
408 let was_ended = row.state == "ended";
409 let finished = finished_subagent(&row);
410 let had_pull = row.pull;
411 let parent_id = row.parent_id.clone();
412 let id = row.id.clone();
413 let at = rfc3339(now);
414 let mut binds = fields.binds();
415 binds.push(at.as_str().into());
416 binds.push(id.as_str().into());
417 let sql = if finished { refresh_sql() } else { resume_sql() };
418 let mut statements = vec![self.db.prepare(sql).bind(&binds)?];
419 // Brought back from having ended: announced with the change.
420 let sent = if was_ended && !finished {
421 let expected = SessionRow {
422 item_id: fields.item.as_ref().map(|(id, _)| id.clone()).or(row.item_id.clone()),
423 item: fields.item.as_ref().map(|(_, number)| *number).or(row.item),
424 pull: fields.pull.or(row.pull),
425 branch: fields.branch.clone().or(row.branch.clone()),
426 head_commit: fields.head_commit.clone().or(row.head_commit.clone()),
427 title: fields.title.clone().or(row.title.clone()),
428 agent_type: fields.agent_type.clone().or(row.agent_type.clone()),
429 task: fields.task.clone().or(row.task.clone()),
430 prompt: fields.prompt.clone().or(row.prompt.clone()),
431 model: fields.model.clone().or(row.model.clone()),
432 result: fields.result.clone().or(row.result.clone()),
433 last_seen_at: at.clone(),
434 state: "active".to_owned(),
435 ended_at: None,
436 end_reason: None,
437 ..row.clone()
438 }
439 .at(now);
440 push_delivery(
441 &mut statements,
442 self.delivery(
443 "session.started",
444 &repo.id,
445 g1t_contracts::events::marked(SessionEvent::of(&expected), Some(actor)),
446 &format!("{id}:started:{at}"),
447 "EXISTS (SELECT 1 FROM sessions WHERE id = ?7 AND state = 'active' AND last_seen_at = ?8)",
448 vec![id.as_str().into(), at.as_str().into()],
449 )?,
450 )
451 } else {
452 None
453 };
454 let updated = match self.db.batch(statements).await?.into_iter().next() {
455 Some(result) => result.results::<SessionRow>()?.into_iter().next(),
456 None => None,
457 };
458 let Some(updated) = updated else {
459 return Ok(not_found());
460 };
461 let session = updated.at(now);
462 self.link_fields(&session, &fields).await?;
463 if finished {
464 return Ok(Outcome::Ok(session));
465 }
466 self.renew_claims(&id, now).await?;
467 if let Some(parent) = &parent_id {
468 self.db.prepare(SEE_PARENT).bind(&[at.as_str().into(), parent.as_str().into()])?.run().await?;
469 }
470 if let Some(number) = session.pull
471 && had_pull != Some(number)
472 {
473 self.move_entries_onto_pull(&repo.id, &id, number).await?;
474 }
475 if was_ended {
476 let data = g1t_contracts::events::marked(SessionEvent::of(&session), Some(actor));
477 self.delivered(sent, "session.started", &repo.id, Some(actor.id.clone()), data).await?;
478 }
479 Ok(Outcome::Ok(session))
480 }
481
482 /// Renews the active claims made from a session by their own length.
483 async fn renew_claims(&self, session_id: &str, now: u64) -> Result<()> {
484 let claims = self
485 .db
486 .prepare("SELECT id, ttl_minutes, expires_at FROM claims WHERE session_id = ? AND state = 'active'")
487 .bind(&[session_id.into()])?
488 .all()
489 .await?
490 .results::<RenewRow>()?;
491 let mut statements = Vec::new();
492 for claim in claims {
493 if let Some(renewed) = renewal(now, claim.ttl_minutes, &claim.expires_at) {
494 statements.push(self.db.prepare(RENEW_CLAIM).bind(&[renewed.into(), claim.id.into()])?);
495 }
496 }
497 if !statements.is_empty() {
498 self.db.batch(statements).await?;
499 }
500 Ok(())
501 }
502
503 /// Entries a session recorded before it named its pull request join
504 /// that pull request's session, after what it has, in their order.
505 async fn move_entries_onto_pull(&self, repo_id: &str, session_id: &str, number: u32) -> Result<()> {
506 let Some(pull) = self.pull(repo_id, number).await? else {
507 return Ok(());
508 };
509 let base = self
510 .db
511 .prepare("SELECT COALESCE(MAX(seq), 0) AS n FROM session_entries WHERE pull_id = ?")
512 .bind(&[pull.id.as_str().into()])?
513 .first::<NumberRow>(None)
514 .await?
515 .map_or(0, |row| row.n);
516 let moved = self
517 .db
518 .prepare(MOVE_ENTRIES)
519 .bind(&[pull.id.as_str().into(), base.into(), session_id.into()])?
520 .run()
521 .await;
522 // An entry recorded on the pull request at the same moment took a
523 // number these needed: they stay the session's alone.
524 if let Err(error) = moved {
525 worker::console_error!("session {session_id}: entries not moved onto #{number}: {error}");
526 }
527 Ok(())
528 }
529
530 /// The caller's own session, as stored.
531 async fn own_session(&self, repo: &Repo, actor: &User, id: &str) -> Result<Outcome<SessionRow>> {
532 Ok(match self.session_row(&repo.id, id.trim()).await? {
533 Some(row) if row.actor_id == actor.id => Outcome::Ok(row),
534 _ => not_found(),
535 })
536 }
537
538 pub(crate) async fn update_session(&self, a: UpdateSessionArgs) -> Result<Outcome<Session>> {
539 let repo = unwrap_or_fail!(self.participating_repo(&a.repo, &a.actor).await?);
540 let row = unwrap_or_fail!(self.own_session(&repo, &a.actor, &a.id).await?);
541 if row.state == "ended" && !revives(row.end_reason.as_deref()) && !finished_subagent(&row) {
542 return Ok(Outcome::fail(FailureCode::Conflict, "This session has ended. Start a new one."));
543 }
544 let (item, pull) = unwrap_or_fail!(self.session_subjects(&repo, a.item, a.pull).await?);
545 let fields = Fields {
546 item,
547 pull,
548 branch: tidy(a.branch.as_deref(), MAX_BRANCH_CHARS),
549 head_commit: tidy(a.head_commit.as_deref(), MAX_COMMIT_CHARS),
550 title: tidy(a.title.as_deref().map(shown::clean).as_deref(), MAX_TITLE_CHARS),
551 agent_type: tidy(a.agent_type.as_deref(), MAX_AGENT_CHARS),
552 task: clip(a.task.as_deref().map(shown::clean).as_deref(), MAX_TASK_CHARS),
553 prompt: clip(a.prompt.as_deref().map(shown::clean).as_deref(), MAX_PROMPT_CHARS),
554 model: tidy(a.model.as_deref(), MAX_MODEL_CHARS),
555 result: clip(a.result.as_deref().map(shown::clean).as_deref(), MAX_RESULT_CHARS),
556 };
557 self.resume(&repo, row, &a.actor, fields).await
558 }
559
560 /// A session `actor` may end: their own, or anyone's when they have
561 /// write access to the repository (one left behind by a harness that
562 /// was killed or never said it ended).
563 async fn endable_session(&self, repo: &Repo, actor: &User, id: &str) -> Result<Outcome<SessionRow>> {
564 let Some(row) = self.session_row(&repo.id, id.trim()).await? else {
565 return Ok(not_found());
566 };
567 let can_push = matches!(crate::allowed(Some(actor), repo, g1t_contracts::access::Capability::Push), Outcome::Ok(()));
568 Ok(if may_end_session(&row.actor_id, &actor.id, can_push) {
569 Outcome::Ok(row)
570 } else {
571 Outcome::fail(FailureCode::Forbidden, "Only whoever runs this session, or someone with write access to the repository, can end it.")
572 })
573 }
574
575 pub(crate) async fn end_session(&self, a: EndSessionArgs) -> Result<Outcome<Session>> {
576 let repo = unwrap_or_fail!(self.participating_repo(&a.repo, &a.actor).await?);
577 let row = unwrap_or_fail!(self.endable_session(&repo, &a.actor, &a.id).await?);
578 let own = row.actor_id == a.actor.id;
579 let now = now_ms();
580 let at = rfc3339(now);
581 // What it reported is its own to say: someone else ending it says nothing for it.
582 let result = if own { clip(a.result.as_deref(), MAX_RESULT_CHARS) } else { None };
583 if row.state == "ended" {
584 // Ended already: it keeps what it reported, or takes it now.
585 if row.result.is_none()
586 && let Some(result) = &result
587 {
588 let updated = self
589 .db
590 .prepare(format!("UPDATE sessions SET result = ? WHERE id = ? RETURNING {SESSION_COLUMNS}"))
591 .bind(&[result.as_str().into(), row.id.as_str().into()])?
592 .first::<SessionRow>(None)
593 .await?;
594 if let Some(updated) = updated {
595 return Ok(Outcome::Ok(updated.at(now)));
596 }
597 }
598 return Ok(Outcome::Ok(row.at(now)));
599 }
600 let reason = session_end_reason(own, a.by_hand, &a.actor.username, tidy(a.reason.as_deref(), MAX_NOTE_CHARS).as_deref());
601 let ended_event = |session: &Session| g1t_contracts::events::marked(SessionEvent::of(session), Some(&a.actor));
602 // Its subagents still going end with it, all in one change with
603 // their updates. (One started meanwhile ends with it too, and is
604 // announced on its own below.)
605 let going: Vec<SessionRow> = self
606 .db
607 .prepare(tree_sql())
608 .bind(&[row.id.as_str().into()])?
609 .all()
610 .await?
611 .results::<SessionRow>()?
612 .into_iter()
613 .filter(|child| child.state == "active")
614 .collect();
615 let end_one = |session: SessionRow, reason: &str, result: Option<String>| {
616 SessionRow {
617 state: "ended".to_owned(),
618 ended_at: Some(at.clone()),
619 end_reason: Some(reason.to_owned()),
620 last_seen_at: at.clone(),
621 result: result.or(session.result.clone()),
622 ..session
623 }
624 .at(now)
625 };
626 let guard = "EXISTS (SELECT 1 FROM sessions WHERE id = ?7 AND state = 'ended' AND ended_at = ?8)";
627 let mut statements = vec![self
628 .db
629 .prepare(format!(
630 "UPDATE sessions SET state = 'ended', ended_at = ?, end_reason = ?, last_seen_at = ?, result = COALESCE(?, result)
631 WHERE id = ? AND state = 'active'
632 RETURNING {SESSION_COLUMNS}"
633 ))
634 .bind(&[at.as_str().into(), reason.as_str().into(), at.as_str().into(), text_or_null(&result), row.id.as_str().into()])?];
635 let expected = end_one(row.clone(), &reason, result.clone());
636 let sent = push_delivery(
637 &mut statements,
638 self.delivery(
639 "session.ended",
640 &repo.id,
641 ended_event(&expected),
642 &format!("{}:ended:{at}", row.id),
643 guard,
644 vec![row.id.as_str().into(), at.as_str().into()],
645 )?,
646 );
647 // Only once it ended here, so a call that lost the race ends nothing.
648 statements.push(self.db.prepare(end_tree_sql()).bind(&[row.id.as_str().into(), at.as_str().into(), "parent ended".into()])?);
649 let tree_index = statements.len() - 1;
650 let mut orphans_sent = Vec::new();
651 for child in going {
652 let child_id = child.id.clone();
653 let expected = end_one(child, "parent ended", None);
654 let delivery = self.delivery(
655 "session.ended",
656 &repo.id,
657 ended_event(&expected),
658 &format!("{child_id}:ended:{at}"),
659 guard,
660 vec![child_id.as_str().into(), at.as_str().into()],
661 )?;
662 orphans_sent.push((child_id, push_delivery(&mut statements, delivery)));
663 }
664 let mut results = self.db.batch(statements).await?;
665 let orphans: Vec<SessionRow> = match results.get(tree_index) {
666 Some(result) => result.results()?,
667 None => Vec::new(),
668 };
669 let ended = match results.drain(..).next() {
670 Some(result) => result.results::<SessionRow>()?.into_iter().next(),
671 None => None,
672 };
673 let Some(ended) = ended else {
674 // Ended by another call meanwhile.
675 return Ok(match self.session_row(&repo.id, &row.id).await? {
676 Some(row) => Outcome::Ok(row.at(now)),
677 None => not_found(),
678 });
679 };
680 let session = ended.at(now);
681 if let Some(result) = &result {
682 self.link_refs(&session.id, &session.repo_id, &result.chars().take(MAX_REF_TEXT).collect::<String>()).await?;
683 }
684 self.delivered(sent, "session.ended", &repo.id, Some(a.actor.id.clone()), ended_event(&session)).await?;
685 for orphan in orphans {
686 let orphan = orphan.at(now);
687 match orphans_sent.iter().position(|(id, _)| *id == orphan.id) {
688 Some(index) => {
689 let (_, sent) = orphans_sent.swap_remove(index);
690 self.delivered(sent, "session.ended", &repo.id, Some(a.actor.id.clone()), ended_event(&orphan)).await?;
691 }
692 None => self.publish("session.ended", &repo.id, &a.actor, SessionEvent::of(&orphan)).await?,
693 }
694 }
695 Ok(Outcome::Ok(session))
696 }
697
698 pub(crate) async fn get_work_session(&self, a: WorkSessionArgs) -> Result<Outcome<SessionDetail>> {
699 let repo = unwrap_or_fail!(self.coordination_repo(&a.repo, &a.viewer).await?);
700 let Some(row) = self.session_row(&repo.id, a.id.trim()).await? else {
701 return Ok(not_found());
702 };
703 let now = now_ms();
704 let entries = self
705 .db
706 .prepare(
707 "SELECT session_seq AS seq, kind, text, tool, \"commit\", at FROM session_entries
708 WHERE session_id = ? AND session_seq > ? ORDER BY session_seq LIMIT ?",
709 )
710 .bind(&[row.id.as_str().into(), a.after_seq.into(), ENTRY_PAGE.into()])?
711 .all()
712 .await?
713 .results::<crate::rows::SessionRow>()?
714 .into_iter()
715 .map(SessionEntry::from)
716 .collect();
717 let mut session = row.at(now);
718 let children = self.subagents_of(&session.id, now).await?;
719 session.subagents = children.iter().filter(|child| child.parent_id.as_deref() == Some(session.id.as_str())).count() as u32;
720 // A subagent's page names the session at the top of its tree.
721 let root = match &session.parent_id {
722 Some(_) => {
723 let mut top = None;
724 let mut at = session.parent_id.clone();
725 for _ in 0..8 {
726 let Some(id) = at else { break };
727 let Some(found) = self.session_row(&repo.id, &id).await? else { break };
728 at = found.parent_id.clone();
729 top = Some(found.at(now));
730 }
731 top
732 }
733 None => None,
734 };
735 let mut ids: Vec<String> = vec![session.id.clone()];
736 ids.extend(children.iter().map(|child| child.id.clone()));
737 let (touched, links, skills) = self.tree_facts(&repo.id, &ids).await?;
738 let updates = self.pending_updates(&session.id).await?;
739 let detail = SessionDetail { session, entries, children, touched, links, skills, root, updates, limited: false };
740 Ok(Outcome::Ok(shown::detail(detail, &a.viewer, &repo.namespace)))
741 }
742
743 /// The sessions tied to some pull requests, for Home (`PullSessionsArgs`).
744 pub(crate) async fn pull_sessions(&self, a: PullSessionsArgs) -> Result<Outcome<Vec<PullSessions>>> {
745 let repo = unwrap_or_fail!(self.coordination_repo(&a.repo, &a.viewer).await?);
746 let mut numbers = a.numbers;
747 numbers.sort_unstable();
748 numbers.dedup();
749 numbers.truncate(MAX_PULL_SESSIONS);
750 if numbers.is_empty() {
751 return Ok(Outcome::Ok(Vec::new()));
752 }
753 let rows = self
754 .db
755 .prepare(PULL_SESSIONS)
756 .bind(&[repo.id.as_str().into(), serde_json::to_string(&numbers)?.into()])?
757 .all()
758 .await?
759 .results::<PullSessionRow>()?;
760 Ok(Outcome::Ok(pull_sessions_of(rows)))
761 }
762
763 pub(crate) async fn list_work_sessions(&self, a: ListWorkSessionsArgs) -> Result<Outcome<Vec<Session>>> {
764 let repo = unwrap_or_fail!(self.coordination_repo(&a.repo, &a.viewer).await?);
765 let now = now_ms();
766 let idle_since = rfc3339(now.saturating_sub(SESSION_IDLE_MS));
767 // Every statement names each bind it is given.
768 let state = match a.state {
769 None => "AND ?2 IS NOT NULL",
770 Some(SessionState::Active) => "AND state = 'active' AND last_seen_at > ?2",
771 Some(SessionState::Idle) => "AND state = 'active' AND last_seen_at <= ?2",
772 Some(SessionState::Ended) => "AND state = 'ended' AND ?2 IS NOT NULL",
773 };
774 // On a work item or pull request: attached, or tied to it any way.
775 let mut linked: Vec<String> = Vec::new();
776 let mut item_id = None;
777 if let Some(number) = a.item {
778 let Some(held) = self.item(&repo.namespace, number).await? else {
779 return Ok(Outcome::Ok(Vec::new()));
780 };
781 linked.extend(self.item_sessions(&repo.id, &held.item.id).await?);
782 // And those on its pull requests here.
783 let pulls: Vec<u32> = self
784 .db
785 .prepare("SELECT number AS n FROM pulls WHERE repo_id = ? AND item_id = ?")
786 .bind(&[repo.id.as_str().into(), held.item.id.as_str().into()])?
787 .all()
788 .await?
789 .results::<NumberRow>()?
790 .into_iter()
791 .map(|row| row.n)
792 .collect();
793 for pull in &pulls {
794 linked.extend(self.linked_sessions(&repo.id, *pull).await?);
795 }
796 linked.extend(self.branch_sessions(&repo.id, &pulls).await?);
797 item_id = Some(held.item.id);
798 }
799 if let Some(number) = a.pull {
800 linked.extend(self.linked_sessions(&repo.id, number).await?);
801 linked.extend(self.branch_sessions(&repo.id, &[number]).await?);
802 }
803 let rows = self
804 .db
805 .prepare(format!(
806 "SELECT {SESSION_COLUMNS} FROM sessions
807 WHERE repo_id = ?1 {state}
808 AND (?6 = 1 OR parent_id IS NULL OR id IN (SELECT value FROM json_each(?7)))
809 AND (?3 IS NULL OR item_id = ?3 OR id IN (SELECT value FROM json_each(?7)))
810 AND (?4 IS NULL OR pull = ?4 OR id IN (SELECT value FROM json_each(?7)))
811 AND (?8 IS NULL OR id IN (SELECT session_id FROM session_touches WHERE repo_id = ?1 AND kind = 'file'
812 AND (value = ?8 OR (substr(?8, -1) = '/' AND substr(value, 1, length(?8)) = ?8))))
813 ORDER BY last_seen_at DESC LIMIT ?5"
814 ))
815 .bind(&[
816 repo.id.as_str().into(),
817 idle_since.as_str().into(),
818 text_or_null(&item_id),
819 number_or_null(a.pull),
820 LIST_LIMIT.into(),
821 u32::from(a.subagents).into(),
822 serde_json::to_string(&linked)?.into(),
823 text_or_null(&tidy(a.path.as_deref().map(|path| path.trim_start_matches("./")), MAX_TOUCH_CHARS)),
824 ])?
825 .all()
826 .await?
827 .results::<SessionRow>()?;
828 let mut sessions: Vec<Session> = rows.into_iter().map(|row| row.at(now)).collect();
829 // With subagents, each tree whole: its subagents, and its top.
830 if a.subagents {
831 let mut more: Vec<Session> = Vec::new();
832 for session in sessions.iter().filter(|session| session.parent_id.is_none()).take(20) {
833 more.extend(self.subagents_of(&session.id, now).await?);
834 }
835 let parents: Vec<String> = sessions
836 .iter()
837 .filter_map(|session| session.parent_id.clone())
838 .filter(|parent| !sessions.iter().any(|session| &session.id == parent))
839 .collect();
840 more.extend(self.sessions_by_id(&repo.id, &parents).await?);
841 for session in more {
842 if !sessions.iter().any(|known| known.id == session.id) {
843 sessions.push(session);
844 }
845 }
846 count_subagents(&mut sessions);
847 } else {
848 self.fill_subagents(&mut sessions).await?;
849 }
850 self.fill_first_prompts(&mut sessions).await?;
851 shown::sessions(&mut sessions, &a.viewer, &repo.namespace);
852 Ok(Outcome::Ok(sessions))
853 }
854
855 /// Notes `text` (from g1t) in the live harness sessions on `pull` or
856 /// its work item, so an agent reading its session hears of it. Never
857 /// fails what it follows: a problem is logged.
858 pub(crate) async fn note_sessions(&self, pull: &Pull, text: &str) {
859 let noted = async {
860 let sessions: Vec<SessionRow> = self
861 .db
862 .prepare(format!(
863 "SELECT {SESSION_COLUMNS} FROM sessions
864 WHERE state = 'active' AND ((repo_id = ?1 AND pull = ?2)
865 OR item_id = (SELECT item_id FROM pulls WHERE repo_id = ?1 AND number = ?2))"
866 ))
867 .bind(&[pull.repo_id.as_str().into(), pull.number.into()])?
868 .all()
869 .await?
870 .results()?;
871 let at = rfc3339(now_ms());
872 let mut statements = Vec::new();
873 for session in sessions {
874 // Kept by the session alone: the pull request's own session
875 // has the note already.
876 statements.push(self.db.prepare(INSERT_ENTRY).bind(&[
877 JsValue::NULL,
878 "note".into(),
879 text.into(),
880 JsValue::NULL,
881 text_or_null(&pull.head_commit),
882 at.as_str().into(),
883 session.id.as_str().into(),
884 JsValue::NULL,
885 ])?);
886 }
887 if !statements.is_empty() {
888 self.db.batch(statements).await?;
889 }
890 Ok::<(), worker::Error>(())
891 };
892 if let Err(error) = noted.await {
893 worker::console_error!("#{}: sessions were not told: {error}", pull.number);
894 }
895 }
896
897 pub(crate) async fn record_session_entries(&self, a: RecordEntriesArgs) -> Result<Outcome<Recorded>> {
898 if a.acknowledge.len() > 100 || a.acknowledge.iter().any(|id| id.len() > 100) {
899 return Ok(Outcome::fail(FailureCode::Invalid, "Acknowledge at most 100 update IDs."));
900 }
901 if a.entries.len() > crate::MAX_ENTRY_BATCH {
902 return Ok(Outcome::fail(
903 FailureCode::Invalid,
904 format!("Send at most {} entries at a time.", crate::MAX_ENTRY_BATCH),
905 ));
906 }
907 if a.touched.len() > MAX_TOUCHES {
908 return Ok(Outcome::fail(FailureCode::Invalid, format!("Say at most {MAX_TOUCHES} things touched at a time.")));
909 }
910 let repo = unwrap_or_fail!(self.participating_repo(&a.repo, &a.actor).await?);
911 let row = unwrap_or_fail!(self.own_session(&repo, &a.actor, &a.id).await?);
912 if row.state == "ended" && !revives(row.end_reason.as_deref()) {
913 return Ok(Outcome::fail(FailureCode::Conflict, "This session has ended. Start a new one."));
914 }
915 // Recording is a heartbeat too.
916 let session = unwrap_or_fail!(self.resume(&repo, row, &a.actor, Fields::default()).await?);
917 let touched = self.record_touches(&session, &a.touched).await?;
918 if a.entries.is_empty() {
919 if touched > 0 {
920 self.announce_progress(&session).await?;
921 }
922 let acknowledged = self.acknowledge_updates(&session.id, &a.acknowledge).await?;
923 return Ok(Outcome::Ok(Recorded { acknowledged, ..Recorded::default() }));
924 }
925 let pull = match session.pull {
926 Some(number) => self.pull(&repo.id, number).await?,
927 None => None,
928 };
929 let pull_id: JsValue = pull.as_ref().map_or(JsValue::NULL, |pull| pull.id.as_str().into());
930 let at = rfc3339(now_ms());
931 let total = a.entries.len() as u32;
932 let mut statements = Vec::with_capacity(a.entries.len());
933 let mut prompts = String::new();
934 for keyed in a.entries {
935 let entry = keyed.entry;
936 let kind = serde_json::to_value(entry.kind)?;
937 // Kept without credentials: a session's page may show it to a
938 // whole team, and its prompts to its author on any device.
939 let mut text: String = shown::clean(&entry.text.chars().take(crate::MAX_ENTRY_CHARS).collect::<String>());
940 if entry.kind == SessionEntryKind::Skill {
941 // A skill is its name, nothing more.
942 text = text.trim().chars().take(MAX_AGENT_CHARS).collect();
943 }
944 if entry.kind == SessionEntryKind::Prompt && prompts.len() < MAX_REF_TEXT {
945 prompts.push_str(&text);
946 prompts.push('\n');
947 }
948 let key = tidy(keyed.key.as_deref(), MAX_KEY_CHARS);
949 // Each takes the next number of the session, and of its pull
950 // request when it has one, itself, so two batches at once
951 // cannot collide; a key already recorded is skipped.
952 statements.push(
953 self.db
954 .prepare(INSERT_ENTRY)
955 .bind(&[
956 pull_id.clone(),
957 kind.as_str().unwrap_or("note").into(),
958 text.into(),
959 text_or_null(&entry.tool),
960 text_or_null(&entry.commit.or_else(|| session.head_commit.clone())),
961 at.as_str().into(),
962 session.id.as_str().into(),
963 text_or_null(&key),
964 ])?,
965 );
966 }
967 let mut recorded = 0;
968 for result in self.db.batch(statements).await? {
969 recorded += result.results::<NumberRow>()?.len() as u32;
970 }
971 if !prompts.is_empty() && recorded > 0 {
972 self.link_refs(&session.id, &repo.id, &prompts).await?;
973 }
974 if let Some(pull) = pull
975 && recorded > 0
976 {
977 self.publish(
978 "session.appended",
979 &repo.id,
980 &a.actor,
981 SessionAppended { pull_id: pull.id, repo_id: repo.id.clone(), number: pull.number, count: recorded },
982 )
983 .await?;
984 }
985 if recorded > 0 || touched > 0 {
986 self.announce_progress(&session).await?;
987 }
988 let acknowledged = self.acknowledge_updates(&session.id, &a.acknowledge).await?;
989 Ok(Outcome::Ok(Recorded { recorded, duplicates: total - recorded, acknowledged }))
990 }
991
992 /// `session.progressed`, at most every [`PROGRESS_EVERY_MS`] for a
993 /// session, so the pages showing it follow along.
994 async fn announce_progress(&self, session: &Session) -> Result<()> {
995 let now = now_ms();
996 let due = self
997 .db
998 .prepare(MARK_PROGRESS)
999 .bind(&[rfc3339(now).into(), session.id.as_str().into(), rfc3339(now.saturating_sub(PROGRESS_EVERY_MS)).into()])?
1000 .first::<serde_json::Value>(None)
1001 .await?;
1002 if due.is_none() {
1003 return Ok(());
1004 }
1005 let mut root_id = None;
1006 let mut up = session.parent_id.clone();
1007 for _ in 0..8 {
1008 let Some(id) = up else { break };
1009 up = self.session_row(&session.repo_id, &id).await?.and_then(|row| row.parent_id);
1010 root_id = Some(id);
1011 }
1012 self.publish_as(
1013 "session.progressed",
1014 &session.repo_id,
1015 Some(session.actor.id.clone()),
1016 SessionProgressed {
1017 repo_id: session.repo_id.clone(),
1018 number: session.pull.or(session.item),
1019 session_id: session.id.clone(),
1020 root_id,
1021 },
1022 )
1023 .await
1024 }
1025}