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