Handle a SessionEnd event. Transitions the session to Ended, saves it, and creates an attestation covering all changes recorded during the session. The attestation is a graph-level audit node — it captures agent identity, timing, and which changes were recorded. Cost and token data are left at zero (they're not available from the hook) and can be enriched later via `atomic agent attest --enrich`
(
&mut self,
event: TurnEvent,
)
| 18 | /// data are left at zero (they're not available from the hook) and |
| 19 | /// can be enriched later via `atomic agent attest --enrich`. |
| 20 | pub(super) async fn handle_session_end( |
| 21 | &mut self, |
| 22 | event: TurnEvent, |
| 23 | ) -> AgentResult<DispatchResult> { |
| 24 | let session_id = &event.session_id; |
| 25 | |
| 26 | let mut session = match self.session_store.load(session_id)? { |
| 27 | Some(s) => s, |
| 28 | None => { |
| 29 | // Session not found — nothing to end |
| 30 | log::info!("SessionEnd for unknown session {} — ignoring", session_id); |
| 31 | return Ok(DispatchResult::new(session_id, Phase::Ended)); |
| 32 | } |
| 33 | }; |
| 34 | |
| 35 | // If a turn is still active, cancel the watcher |
| 36 | if session.is_turn_active() { |
| 37 | if let Err(e) = self.watcher.cancel_turn().await { |
| 38 | log::warn!( |
| 39 | "Failed to cancel turn watcher for session {}: {}", |
| 40 | session_id, |
| 41 | e |
| 42 | ); |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | // Flush any unrecorded turn BEFORE finalizing. Headless agents such as |
| 47 | // Cursor's CLI fire sessionStart/postToolUse/sessionEnd but no per-turn |
| 48 | // `stop` (TurnEnd), so the turn's file changes are still uncommitted at |
| 49 | // session end. Record them now — crucially *before* switching back to |
| 50 | // the parent view below, which restores the working copy and would |
| 51 | // otherwise discard the agent's work. Idempotent: agents that already |
| 52 | // recorded each turn on `stop` leave a clean working copy here, so |
| 53 | // `record_turn` returns `EmptyTurn` and this is a no-op for them. |
| 54 | { |
| 55 | // Ensure the working copy is on the session's agent view before |
| 56 | // recording. session-start aligns to it, but that can drift back to |
| 57 | // the parent view by session end (observed with Cursor's CLI), which |
| 58 | // makes `record_turn` see a view mismatch and record nothing. Align |
| 59 | // explicitly so the agent's uncommitted files are recorded on the |
| 60 | // agent view. Best-effort — non-fatal if it can't switch. |
| 61 | match atomic_repository::Repository::open_existing(&self.repo_root) { |
| 62 | Ok(mut repo) => { |
| 63 | if repo.current_view() != session.view_name { |
| 64 | if let Err(e) = repo.align_to_view(&session.view_name) { |
| 65 | log::warn!( |
| 66 | "SessionEnd: could not align to agent view '{}': {} (non-fatal)", |
| 67 | session.view_name, |
| 68 | e, |
| 69 | ); |
| 70 | } else { |
| 71 | log::info!( |
| 72 | "SessionEnd: aligned working copy to agent view '{}' before flush", |
| 73 | session.view_name, |
| 74 | ); |
| 75 | } |
| 76 | } |
| 77 | } |
no test coverage detected