Record an agent turn as an Atomic change. This is the function that bridges the agent world into the VCS world. It builds a `ChangeHeader`, `Provenance`, and `SessionEnvelope`, then calls the repository's `record()` method to create a proper content-addressed, hashable, pushable Atomic change. # Arguments `repo_root` — Path to the repository root (where `.atomic/` lives). The repository is open
(
repo_root: &Path,
options: &TurnRecordOptions<'_>,
)
| 136 | /// (no files changed since the last recorded state). |
| 137 | /// Returns `AgentError::RecordFailed` if the repository record operation fails. |
| 138 | pub fn record_turn( |
| 139 | repo_root: &Path, |
| 140 | options: &TurnRecordOptions<'_>, |
| 141 | ) -> AgentResult<TurnRecordOutcome> { |
| 142 | let mut manifest = scope::manifest(options)?; |
| 143 | // Step 1: Open the repository read-only for the initial status check. |
| 144 | // This can coexist with other readers. Wait for a transient incompatible |
| 145 | // writer before deciding whether work or untracked files exist. |
| 146 | let mut repo = atomic_repository::Repository::open_readonly_wait( |
| 147 | repo_root, |
| 148 | std::time::Duration::from_secs(10), |
| 149 | ) |
| 150 | .map_err(|e| AgentError::RecordFailed { |
| 151 | session_id: options.session.session_id.clone(), |
| 152 | turn_number: options.turn_number, |
| 153 | reason: format!("Failed to open repository (readonly): {}", e), |
| 154 | })?; |
| 155 | |
| 156 | // `status()` reads current_view, while `record()` writes to session.view_name. |
| 157 | // Align the read-only handle before the first status check; this keeps the |
| 158 | // no-lock fast path and prevents direct callers from seeing false EmptyTurn. |
| 159 | repo.set_current_view_in_memory(&options.session.view_name); |
| 160 | |
| 161 | // Step 2: Status — find out what the agent changed. |
| 162 | // Include untracked files because agent turns commonly create new source, |
| 163 | // config, and test files. Those must be auto-added before recording so the |
| 164 | // turn produces an Atomic change with provenance instead of leaving files |
| 165 | // untracked in the working copy. |
| 166 | let status = repo |
| 167 | .status(atomic_repository::status::StatusOptions::fast().with_untracked(true)) |
| 168 | .map_err(|e| AgentError::RecordFailed { |
| 169 | session_id: options.session.session_id.clone(), |
| 170 | turn_number: options.turn_number, |
| 171 | reason: format!("Failed to get repository status: {}", e), |
| 172 | })?; |
| 173 | |
| 174 | // Restart-proof the deletion scope. The plugin's ownership claims live in |
| 175 | // its process memory; a plugin restart mid-session (e.g. after an |
| 176 | // ambiguity lockout) silently drops every claim, and deletions of files |
| 177 | // this session recorded earlier then sit unrecorded forever — no later |
| 178 | // manifest will ever mention them again. Those deletions are still |
| 179 | // attributable from the persisted session state: `files_touched` holds |
| 180 | // exactly the paths this session's own changes introduced or modified. |
| 181 | // Augment the manifest with their Deleted status entries so a lost claim |
| 182 | // cannot strand a deletion. Files the session never recorded stay |
| 183 | // unclaimed, keeping foreign edits out of scope. |
| 184 | if let Some(files) = manifest.as_mut() { |
| 185 | let touched: std::collections::HashSet<&str> = options |
| 186 | .session |
| 187 | .files_touched |
| 188 | .iter() |
| 189 | .map(String::as_str) |
| 190 | .collect(); |
| 191 | for entry in status.entries() { |
| 192 | if entry.status() != atomic_repository::status::FileStatus::Deleted { |
| 193 | continue; |
| 194 | } |
| 195 | let path = entry.path().to_string_lossy().to_string(); |