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.

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