Handle a TurnEnd event (Stop). Records an Atomic change for the turn (status → add → record), then transitions the session back to Idle. The recording workflow lets the repository figure out what changed: 1. `repo.status()` — find modified, deleted, and untracked files 2. `repo.add()` — track any new files the agent created 3. `repo.record(all: true)` — record everything that's dirty This avoid
(
&mut self,
event: TurnEvent,
)
| 132 | /// snapshots between TurnStart and TurnEnd. Instead, we ask the |
| 133 | /// repository what changed since the last recorded state. |
| 134 | pub(super) async fn handle_turn_end( |
| 135 | &mut self, |
| 136 | event: TurnEvent, |
| 137 | ) -> AgentResult<DispatchResult> { |
| 138 | let session_id = &event.session_id; |
| 139 | let _turn_end_lock = match self.try_turn_end_lock(session_id) { |
| 140 | TurnEndLock::Acquired(guard) => Some(guard), |
| 141 | TurnEndLock::Busy => { |
| 142 | log::warn!( |
| 143 | "Turn end for session {} is already being recorded; skipping duplicate Stop hook", |
| 144 | session_id |
| 145 | ); |
| 146 | return Ok(DispatchResult::new(session_id, phase::Phase::Idle) |
| 147 | .with_warning("duplicate Stop hook skipped: turn already recording")); |
| 148 | } |
| 149 | TurnEndLock::Unavailable => None, |
| 150 | }; |
| 151 | |
| 152 | // Fast gate: check if anything changed since the last record. |
| 153 | // This bypasses the entire status machinery (TREE scan, filesystem |
| 154 | // walk, etc.) and just checks the pristine database mtime. |
| 155 | // If the DB hasn't been written since the last record, nothing |
| 156 | // in the working copy could have been recorded — but files may |
| 157 | // have been edited. We check the working copy for recent mtimes |
| 158 | // by scanning only the repo root (not recursively) and common |
| 159 | // source directories. |
| 160 | if !self.has_working_copy_changes() { |
| 161 | log::info!( |
| 162 | "Turn end for session {} — no changes detected, skipping record", |
| 163 | session_id |
| 164 | ); |
| 165 | return Ok(DispatchResult::new(session_id, phase::Phase::Idle)); |
| 166 | } |
| 167 | |
| 168 | let mut session = self.load_or_create_session(session_id, &event)?; |
| 169 | |
| 170 | // Extract model/provider from the TurnEnd event's raw_json. |
| 171 | // OpenCode sends model and provider in every stop payload. |
| 172 | // This is the last chance to capture the info before recording, |
| 173 | // in case TurnStart didn't have it (e.g., session was created |
| 174 | // outside the plugin, or the chat.message hook didn't fire). |
| 175 | if let Some(ref raw) = event.raw_json { |
| 176 | if let Some(model) = raw.get("model").and_then(|v| v.as_str()) { |
| 177 | if !model.is_empty() { |
| 178 | session.model = model.to_string(); |
| 179 | } |
| 180 | } |
| 181 | if let Some(provider) = raw.get("provider").and_then(|v| v.as_str()) { |
| 182 | if !provider.is_empty() { |
| 183 | session.agent_vendor = provider.to_string(); |
| 184 | } |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | // Release the watcher if it was active (best-effort, ignore errors) |
| 189 | if self.watcher.is_active() { |
| 190 | let _ = self.watcher.cancel_turn().await; |
| 191 | } |
no test coverage detected