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<'_>,
)
| 134 | /// (no files changed since the last recorded state). |
| 135 | /// Returns `AgentError::RecordFailed` if the repository record operation fails. |
| 136 | pub fn record_turn( |
| 137 | repo_root: &Path, |
| 138 | options: &TurnRecordOptions<'_>, |
| 139 | ) -> AgentResult<TurnRecordOutcome> { |
| 140 | // Step 1: Open the repository read-only for the initial status check. |
| 141 | // This avoids blocking on the redb write lock — we only need read access |
| 142 | // to decide whether there's work to do and which files are untracked. |
| 143 | let mut repo = atomic_repository::Repository::open_readonly(repo_root).map_err(|e| { |
| 144 | AgentError::RecordFailed { |
| 145 | session_id: options.session.session_id.clone(), |
| 146 | turn_number: options.turn_number, |
| 147 | reason: format!("Failed to open repository (readonly): {}", e), |
| 148 | } |
| 149 | })?; |
| 150 | |
| 151 | // `status()` reads current_view, while `record()` writes to session.view_name. |
| 152 | // Align the read-only handle before the first status check; this keeps the |
| 153 | // no-lock fast path and prevents direct callers from seeing false EmptyTurn. |
| 154 | repo.set_current_view_in_memory(&options.session.view_name); |
| 155 | |
| 156 | // Step 2: Status — find out what the agent changed. |
| 157 | // Include untracked files because agent turns commonly create new source, |
| 158 | // config, and test files. Those must be auto-added before recording so the |
| 159 | // turn produces an Atomic change with provenance instead of leaving files |
| 160 | // untracked in the working copy. |
| 161 | let status = repo |
| 162 | .status(atomic_repository::status::StatusOptions::fast().with_untracked(true)) |
| 163 | .map_err(|e| AgentError::RecordFailed { |
| 164 | session_id: options.session.session_id.clone(), |
| 165 | turn_number: options.turn_number, |
| 166 | reason: format!("Failed to get repository status: {}", e), |
| 167 | })?; |
| 168 | |
| 169 | // Check if there's anything to record at all |
| 170 | if status.is_clean() && status.untracked_count() == 0 { |
| 171 | return Err(AgentError::EmptyTurn { |
| 172 | session_id: options.session.session_id.clone(), |
| 173 | turn_number: options.turn_number, |
| 174 | }); |
| 175 | } |
| 176 | |
| 177 | // Step 3: Add — track any new files the agent created |
| 178 | // Agents create new files all the time (new modules, tests, configs). |
| 179 | // These show up as "untracked" in status. We add them before recording |
| 180 | // so they're included in the change. |
| 181 | // |
| 182 | // We filter out common large directories (node_modules, target, etc.) |
| 183 | // that agents may create as side effects (e.g., `npm install`). These |
| 184 | // would make the hook extremely slow and are never intended to be |
| 185 | // version-controlled. The .atomicignore file provides user-level control, |
| 186 | // but these defaults protect against the common case where no ignore |
| 187 | // file exists yet. |
| 188 | let untracked_paths: Vec<String> = status |
| 189 | .untracked() |
| 190 | .map(|e| e.path().to_string_lossy().to_string()) |
| 191 | .filter(|p| !should_ignore_untracked(p)) |
| 192 | .collect(); |
| 193 |