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