Skip to main content

apollo/
autoresearch.rs

1//! Metric-driven autonomous experimentation.
2//!
3//! Autoresearch is deliberately separate from [`crate::autonomous`]. The
4//! autonomous TODO loop completes one task and validates it; this loop keeps a
5//! measurable objective, accepts only improvements, and resumes from a small
6//! durable ledger.
7
8use std::cmp::Ordering;
9use std::collections::HashSet;
10use std::future::Future;
11use std::path::{Path, PathBuf};
12use std::time::{Duration, Instant};
13
14use anyhow::{bail, Context};
15use serde::{Deserialize, Serialize};
16use sha2::{Digest, Sha256};
17
18use crate::agent::NullChannel;
19use crate::channels::IncomingMessage;
20
21const WALL_CLOCK_BUDGET_EXHAUSTED: &str = "autoresearch wall-clock budget exhausted";
22const MAX_IGNORED_FILE_BYTES: u64 = 16 * 1024 * 1024;
23const MAX_IGNORED_SNAPSHOT_BYTES: u64 = 64 * 1024 * 1024;
24const VOLATILE_IGNORED_ROOTS: &[&str] =
25    &["target", "node_modules", ".venv", "vendor", "dist", "build"];
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(default)]
29pub struct AutoresearchConfig {
30    /// Human-readable objective supplied to the agent.
31    pub objective: String,
32    /// Trusted local command that prints one numeric metric value.
33    pub metric_command: String,
34    /// `minimize` or `maximize`.
35    pub direction: String,
36    /// Optional command that must pass before the metric is considered.
37    pub validation_command: String,
38    /// Number of attempts for the validation command.
39    pub validation_retries: usize,
40    /// Timeout for each validation and metric process.
41    pub command_timeout_secs: u64,
42    /// Number of metric samples per measurement.
43    pub samples: usize,
44    /// Minimum accepted improvement as a percentage. Defaults to strict.
45    pub min_improvement_percent: f64,
46    /// Maximum number of iterations for one invocation.
47    pub max_iterations: usize,
48    /// Wall-clock budget for one invocation. Zero disables the budget.
49    pub max_duration_secs: u64,
50    /// Durable ledger path, relative to the workspace unless absolute.
51    pub ledger_path: String,
52    /// Model override. Empty uses the runner's default.
53    pub model: String,
54}
55
56impl Default for AutoresearchConfig {
57    fn default() -> Self {
58        Self {
59            objective: String::new(),
60            metric_command: String::new(),
61            direction: "minimize".to_string(),
62            validation_command: String::new(),
63            validation_retries: 1,
64            command_timeout_secs: 300,
65            samples: 3,
66            min_improvement_percent: 0.0,
67            max_iterations: 10,
68            max_duration_secs: 1800,
69            ledger_path: ".apollo/autoresearch-ledger.toml".to_string(),
70            model: String::new(),
71        }
72    }
73}
74
75impl AutoresearchConfig {
76    fn validate(&self) -> anyhow::Result<()> {
77        if self.objective.trim().is_empty() {
78            bail!("autoresearch objective must not be empty");
79        }
80        if self.metric_command.trim().is_empty() {
81            bail!("autoresearch metric_command must not be empty");
82        }
83        if !matches!(
84            self.direction.trim().to_ascii_lowercase().as_str(),
85            "minimize" | "maximize"
86        ) {
87            bail!("autoresearch direction must be 'minimize' or 'maximize'");
88        }
89        if self.samples == 0 {
90            bail!("autoresearch samples must be at least 1");
91        }
92        if self.max_iterations == 0 {
93            bail!("autoresearch max_iterations must be at least 1");
94        }
95        if self.validation_retries == 0 {
96            bail!("autoresearch validation_retries must be at least 1");
97        }
98        if self.command_timeout_secs == 0 {
99            bail!("autoresearch command_timeout_secs must be at least 1");
100        }
101        if !self.min_improvement_percent.is_finite() || self.min_improvement_percent < 0.0 {
102            bail!("autoresearch min_improvement_percent must be a finite non-negative number");
103        }
104        Ok(())
105    }
106
107    pub fn load(path: &Path) -> anyhow::Result<Self> {
108        let content = std::fs::read_to_string(path)
109            .with_context(|| format!("reading autoresearch spec {}", path.display()))?;
110        let config: Self = toml::from_str(&content)
111            .with_context(|| format!("parsing autoresearch spec {}", path.display()))?;
112        config.validate()?;
113        Ok(config)
114    }
115
116    fn ledger_path(&self, workspace: &Path) -> PathBuf {
117        let path = PathBuf::from(&self.ledger_path);
118        if path.is_absolute() {
119            path
120        } else {
121            workspace.join(path)
122        }
123    }
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
127#[serde(rename_all = "snake_case")]
128pub enum ExperimentDecision {
129    Baseline,
130    Accepted,
131    Rejected,
132    Failed,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct ExperimentRecord {
137    pub iteration: usize,
138    pub hypothesis: String,
139    pub commit: Option<String>,
140    pub metric: Option<f64>,
141    pub baseline: f64,
142    pub delta_percent: Option<f64>,
143    pub decision: ExperimentDecision,
144    pub reason: String,
145    pub timestamp: String,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct AutoresearchLedger {
150    pub objective: String,
151    pub direction: String,
152    pub best_metric: f64,
153    pub best_commit: String,
154    /// Branch on which this experiment started and accepted commits land.
155    #[serde(default)]
156    pub branch: String,
157    /// Stable chat id so a fresh run cannot inherit another run's history.
158    #[serde(default)]
159    pub chat_id: String,
160    /// Hash of the metric/validation definition used to produce this ledger.
161    #[serde(default)]
162    pub spec_fingerprint: String,
163    pub records: Vec<ExperimentRecord>,
164}
165
166impl AutoresearchLedger {
167    fn new(config: &AutoresearchConfig, baseline: f64, commit: String, branch: String) -> Self {
168        Self {
169            objective: config.objective.clone(),
170            direction: config.direction.trim().to_ascii_lowercase(),
171            best_metric: baseline,
172            best_commit: commit,
173            branch,
174            chat_id: format!("autoresearch-{}", uuid::Uuid::new_v4()),
175            spec_fingerprint: spec_fingerprint(config),
176            records: vec![ExperimentRecord {
177                iteration: 0,
178                hypothesis: "Initial measurement".to_string(),
179                commit: None,
180                metric: Some(baseline),
181                baseline,
182                delta_percent: Some(0.0),
183                decision: ExperimentDecision::Baseline,
184                reason: "baseline".to_string(),
185                timestamp: chrono::Utc::now().to_rfc3339(),
186            }],
187        }
188    }
189}
190
191/// Controller for one bounded autoresearch run.
192pub struct AutoresearchLoop {
193    config: AutoresearchConfig,
194    workspace: PathBuf,
195}
196
197impl AutoresearchLoop {
198    pub fn new(config: AutoresearchConfig, workspace: PathBuf) -> Self {
199        Self { config, workspace }
200    }
201
202    pub async fn run(
203        &self,
204        agent: std::sync::Arc<crate::agent::AgentRunner>,
205        resume: bool,
206    ) -> anyhow::Result<AutoresearchLedger> {
207        self.config.validate()?;
208        let started = Instant::now();
209        let ledger_path = self.config.ledger_path(&self.workspace);
210        ensure_ledger_path_safe(&self.workspace, &ledger_path).await?;
211        ensure_clean_workspace(&self.workspace).await?;
212        let branch = git_branch(&self.workspace).await?;
213        if branch.is_empty() {
214            bail!("autoresearch requires a named branch; detached HEAD is not supported");
215        }
216
217        let mut ledger = if resume {
218            let ledger = load_ledger(&ledger_path).await?;
219            if ledger.objective != self.config.objective
220                || ledger.direction != self.config.direction.trim().to_ascii_lowercase()
221            {
222                bail!("autoresearch ledger does not match the current spec; use a new ledger path");
223            }
224            if ledger.spec_fingerprint != spec_fingerprint(&self.config) {
225                bail!(
226                    "autoresearch ledger uses a different metric definition; use a new ledger path"
227                );
228            }
229            if ledger.branch.is_empty() || ledger.branch != branch {
230                bail!(
231                    "autoresearch ledger belongs to branch `{}`, current branch is `{}`",
232                    if ledger.branch.is_empty() {
233                        "<unknown>"
234                    } else {
235                        &ledger.branch
236                    },
237                    branch
238                );
239            }
240            let head = git_rev(&self.workspace).await?;
241            if ledger.best_commit.is_empty() || ledger.best_commit != head {
242                bail!(
243                    "autoresearch ledger best commit {} does not match workspace HEAD {}; restore the recorded commit or use a new ledger path",
244                    if ledger.best_commit.is_empty() { "<unknown>" } else { &ledger.best_commit },
245                    head
246                );
247            }
248            if ledger.chat_id.is_empty() {
249                bail!("autoresearch ledger has no run identity; use a new ledger path");
250            }
251            ledger
252        } else {
253            let commit = git_rev(&self.workspace).await?;
254            let baseline_ignored_state = capture_ignored_state(&self.workspace).await?;
255            let baseline_result = async {
256                if !run_validation(
257                    &self.config,
258                    &self.workspace,
259                    started,
260                    self.config.max_duration_secs,
261                )
262                .await?
263                {
264                    bail!("autoresearch baseline validation failed");
265                }
266                ensure_tracked_state_unchanged(&self.workspace, &branch, &commit).await?;
267                let baseline = measure_metric(
268                    &self.config,
269                    &self.workspace,
270                    started,
271                    self.config.max_duration_secs,
272                )
273                .await?;
274                ensure_tracked_state_unchanged(&self.workspace, &branch, &commit).await?;
275                Ok::<_, anyhow::Error>(baseline)
276            }
277            .await;
278            let baseline = match baseline_result {
279                Ok(baseline) => baseline,
280                Err(error) => {
281                    restore_baseline_state(
282                        &self.workspace,
283                        &branch,
284                        &commit,
285                        &baseline_ignored_state,
286                    )
287                    .await
288                    .with_context(|| format!("baseline cleanup failed after: {error}"))?;
289                    return Err(error);
290                }
291            };
292            let ledger = AutoresearchLedger::new(&self.config, baseline, commit, branch.clone());
293            save_ledger(&ledger_path, &ledger).await?;
294            ledger
295        };
296
297        let start = ledger
298            .records
299            .iter()
300            .map(|record| record.iteration)
301            .max()
302            .unwrap_or(0)
303            .saturating_add(1);
304
305        for iteration in start..start.saturating_add(self.config.max_iterations) {
306            if self.config.max_duration_secs > 0
307                && started.elapsed() >= Duration::from_secs(self.config.max_duration_secs)
308            {
309                tracing::info!("autoresearch wall-clock budget exhausted");
310                break;
311            }
312            let checkpoint = git_rev(&self.workspace).await?;
313            if checkpoint != ledger.best_commit {
314                bail!(
315                    "autoresearch workspace HEAD {} does not match ledger best commit {}",
316                    checkpoint,
317                    ledger.best_commit
318                );
319            }
320            if git_branch(&self.workspace).await? != ledger.branch {
321                bail!("autoresearch branch changed while the run was in progress");
322            }
323            let ignored_state = capture_ignored_state(&self.workspace).await?;
324            let previous_best = ledger.best_metric;
325            let prompt = format!(
326                "You are running one bounded autoresearch iteration.\n\n\
327                 Objective: {objective}\n\
328                 Metric command (must print one numeric value): {metric}\n\
329                 Direction: {direction}\n\
330                 Current best metric: {best}\n\n\
331                 Form exactly one concrete hypothesis, implement only that experiment,\
332                 and leave the workspace in the candidate state. Do not commit, reset,\
333                 edit the autoresearch ledger, or claim success without making a change.\n\n\
334                 Hypothesis: ",
335                objective = self.config.objective,
336                metric = self.config.metric_command,
337                direction = self.config.direction,
338                best = ledger.best_metric,
339            );
340
341            let null_channel = NullChannel::new("autoresearch");
342            let message = IncomingMessage {
343                id: uuid::Uuid::new_v4().to_string(),
344                sender_id: "autoresearch".to_string(),
345                sender_name: Some("Autoresearch".to_string()),
346                chat_id: ledger.chat_id.clone(),
347                text: prompt,
348                is_group: false,
349                reply_to: None,
350                timestamp: chrono::Utc::now(),
351            };
352            let turn = async {
353                if self.config.model.trim().is_empty() {
354                    agent.handle_message(&message, &null_channel).await
355                } else {
356                    agent
357                        .handle_message_with_model(
358                            &message,
359                            &null_channel,
360                            Some(self.config.model.trim()),
361                        )
362                        .await
363                }
364            };
365            let result = run_with_budget(started, self.config.max_duration_secs, turn).await;
366
367            // The agent may edit files, but it must not move the experiment's
368            // branch or checkpoint. Verify before running trusted commands so
369            // a rejection can never reset an unrelated branch.
370            ensure_experiment_state(&self.workspace, &ledger.branch, &checkpoint).await?;
371            restore_ignored_state(&self.workspace, &ignored_state).await?;
372            let candidate_status = git_status(&self.workspace).await?;
373
374            let hypothesis = result
375                .as_ref()
376                .map(|response| first_line(response).unwrap_or_else(|| "agent experiment".into()))
377                .unwrap_or_else(|error| format!("agent error: {error}"));
378
379            let (decision, metric, reason) = match result {
380                Err(error) => (
381                    ExperimentDecision::Failed,
382                    None,
383                    format!("agent error: {error}"),
384                ),
385                Ok(_) => {
386                    let evaluation = async {
387                        if !run_validation(
388                            &self.config,
389                            &self.workspace,
390                            started,
391                            self.config.max_duration_secs,
392                        )
393                        .await?
394                        {
395                            return Ok((
396                                ExperimentDecision::Rejected,
397                                None,
398                                "validation failed".to_string(),
399                            ));
400                        }
401                        match measure_metric(
402                            &self.config,
403                            &self.workspace,
404                            started,
405                            self.config.max_duration_secs,
406                        )
407                        .await
408                        {
409                            Ok(value) if is_better(&self.config, value, previous_best) => Ok((
410                                ExperimentDecision::Accepted,
411                                Some(value),
412                                "metric improved".to_string(),
413                            )),
414                            Ok(value) => Ok((
415                                ExperimentDecision::Rejected,
416                                Some(value),
417                                "metric did not improve".to_string(),
418                            )),
419                            Err(error) => Ok((ExperimentDecision::Failed, None, error.to_string())),
420                        }
421                    }
422                    .await;
423                    match evaluation {
424                        Err(error) if is_budget_error(&error) => return Err(error),
425                        Ok(result) => result,
426                        Err(error) => (
427                            ExperimentDecision::Failed,
428                            None,
429                            format!("evaluation error: {error}"),
430                        ),
431                    }
432                }
433            };
434
435            // Validation and metric commands are trusted shell, but they are
436            // still not allowed to move the branch or checkpoint. Their
437            // ignored-file side effects are also discarded before deciding.
438            ensure_experiment_state(&self.workspace, &ledger.branch, &checkpoint).await?;
439            ensure_status_unchanged(&self.workspace, &candidate_status, "validation or metric")
440                .await?;
441            restore_ignored_state(&self.workspace, &ignored_state).await?;
442
443            let delta_percent = metric.map(|value| percent_delta(previous_best, value));
444            let accepted = decision == ExperimentDecision::Accepted;
445            let commit = if accepted {
446                match commit_experiment(
447                    &self.workspace,
448                    iteration,
449                    started,
450                    self.config.max_duration_secs,
451                )
452                .await
453                {
454                    Ok(commit) => Some(commit),
455                    Err(error) => {
456                        // A timed-out hook may have left git add's index
457                        // changes behind. Reset only if the branch and HEAD
458                        // are still the checkpoint we verified above.
459                        let current_branch = git_branch(&self.workspace).await?;
460                        let current_head = git_rev(&self.workspace).await?;
461                        if current_branch == ledger.branch && current_head == checkpoint {
462                            restore_checkpoint(&self.workspace, &checkpoint).await?;
463                            restore_ignored_state(&self.workspace, &ignored_state).await?;
464                        }
465                        bail!("autoresearch acceptance commit failed: {error}");
466                    }
467                }
468            } else {
469                restore_checkpoint(&self.workspace, &checkpoint).await?;
470                restore_ignored_state(&self.workspace, &ignored_state).await?;
471                None
472            };
473
474            if let Some(value) = metric.filter(|_| accepted) {
475                ledger.best_metric = value;
476                ledger.best_commit = commit.clone().unwrap_or(checkpoint);
477            }
478            ledger.records.push(ExperimentRecord {
479                iteration,
480                hypothesis,
481                commit,
482                metric,
483                baseline: previous_best,
484                delta_percent,
485                decision,
486                reason,
487                timestamp: chrono::Utc::now().to_rfc3339(),
488            });
489            save_ledger(&ledger_path, &ledger).await?;
490            tracing::info!(
491                iteration,
492                best_metric = ledger.best_metric,
493                "autoresearch iteration complete"
494            );
495        }
496
497        Ok(ledger)
498    }
499}
500
501async fn load_ledger(path: &Path) -> anyhow::Result<AutoresearchLedger> {
502    let content = tokio::fs::read_to_string(path)
503        .await
504        .with_context(|| format!("reading autoresearch ledger {}", path.display()))?;
505    Ok(toml::from_str(&content)?)
506}
507
508#[derive(Serialize)]
509struct ExperimentDefinition<'a> {
510    objective: &'a str,
511    metric_command: &'a str,
512    direction: &'a str,
513    validation_command: &'a str,
514    validation_retries: usize,
515    command_timeout_secs: u64,
516    samples: usize,
517    min_improvement_percent: f64,
518    max_iterations: usize,
519    max_duration_secs: u64,
520    model: &'a str,
521}
522
523fn spec_fingerprint(config: &AutoresearchConfig) -> String {
524    let direction = config.direction.trim().to_ascii_lowercase();
525    let definition = ExperimentDefinition {
526        objective: &config.objective,
527        metric_command: &config.metric_command,
528        direction: &direction,
529        validation_command: &config.validation_command,
530        validation_retries: config.validation_retries,
531        command_timeout_secs: config.command_timeout_secs,
532        samples: config.samples,
533        min_improvement_percent: config.min_improvement_percent,
534        max_iterations: config.max_iterations,
535        max_duration_secs: config.max_duration_secs,
536        model: &config.model,
537    };
538    let encoded = serde_json::to_vec(&definition).expect("experiment definition is serializable");
539    format!("{:x}", Sha256::digest(encoded))
540}
541
542#[derive(Debug)]
543struct IgnoredFile {
544    relative: PathBuf,
545    backup_relative: Option<PathBuf>,
546    symlink_target: Option<PathBuf>,
547    #[cfg(unix)]
548    mode: u32,
549}
550
551#[derive(Debug)]
552struct IgnoredWorkspaceState {
553    backup_dir: PathBuf,
554    files: Vec<IgnoredFile>,
555}
556
557impl Drop for IgnoredWorkspaceState {
558    fn drop(&mut self) {
559        let _ = std::fs::remove_dir_all(&self.backup_dir);
560    }
561}
562
563fn is_volatile_ignored_path(path: &Path) -> bool {
564    path.components().next().is_some_and(|component| {
565        let std::path::Component::Normal(root) = component else {
566            return false;
567        };
568        VOLATILE_IGNORED_ROOTS
569            .iter()
570            .any(|candidate| root == *candidate)
571    })
572}
573
574async fn ignored_paths(workspace: &Path) -> anyhow::Result<Vec<PathBuf>> {
575    let output = git_command(
576        workspace,
577        &[
578            "ls-files",
579            "--others",
580            "--ignored",
581            "--exclude-standard",
582            "-z",
583            "--",
584            ".",
585            ":(exclude)target",
586            ":(exclude)target/**",
587            ":(exclude)node_modules",
588            ":(exclude)node_modules/**",
589            ":(exclude).venv",
590            ":(exclude).venv/**",
591            ":(exclude)vendor",
592            ":(exclude)vendor/**",
593            ":(exclude)dist",
594            ":(exclude)dist/**",
595            ":(exclude)build",
596            ":(exclude)build/**",
597        ],
598    )
599    .await?;
600    output
601        .split('\0')
602        .filter(|path| !path.is_empty())
603        // Build output can contain millions of files and is deliberately
604        // treated as disposable process state rather than experiment input.
605        // The source/configuration files that can affect an experiment are
606        // still snapshotted and restored below.
607        .filter(|path| !is_volatile_ignored_path(Path::new(path)))
608        .map(|path| {
609            let relative = PathBuf::from(path);
610            validate_workspace_relative_path(&relative)?;
611            Ok(relative)
612        })
613        .collect()
614}
615
616fn validate_workspace_relative_path(path: &Path) -> anyhow::Result<()> {
617    if path.is_absolute()
618        || path
619            .components()
620            .any(|component| matches!(component, std::path::Component::ParentDir))
621    {
622        bail!(
623            "git returned an unsafe workspace-relative path: {}",
624            path.display()
625        );
626    }
627    Ok(())
628}
629
630async fn capture_ignored_state(workspace: &Path) -> anyhow::Result<IgnoredWorkspaceState> {
631    let backup_dir = std::env::temp_dir().join(format!(
632        "apollo-autoresearch-ignored-{}",
633        uuid::Uuid::new_v4()
634    ));
635    tokio::fs::create_dir_all(&backup_dir).await?;
636    let result = async {
637        let mut files = Vec::new();
638        let mut total_bytes = 0u64;
639        for relative in ignored_paths(workspace).await? {
640            let path = workspace.join(&relative);
641            let metadata = tokio::fs::symlink_metadata(&path)
642                .await
643                .with_context(|| format!("reading ignored path metadata: {}", path.display()))?;
644            let file_type = metadata.file_type();
645            if file_type.is_symlink() {
646                files.push(IgnoredFile {
647                    relative,
648                    backup_relative: None,
649                    symlink_target: Some(tokio::fs::read_link(&path).await?),
650                    #[cfg(unix)]
651                    mode: 0,
652                });
653            } else if file_type.is_file() {
654                let size = metadata.len();
655                if size > MAX_IGNORED_FILE_BYTES {
656                    bail!(
657                        "ignored file {} is {} bytes; autoresearch refuses to snapshot files larger than {} bytes",
658                        path.display(),
659                        size,
660                        MAX_IGNORED_FILE_BYTES
661                    );
662                }
663                total_bytes = total_bytes.saturating_add(size);
664                if total_bytes > MAX_IGNORED_SNAPSHOT_BYTES {
665                    bail!(
666                        "ignored workspace state exceeds the {} byte autoresearch snapshot limit; use a dedicated workspace or exclude dependency/data trees",
667                        MAX_IGNORED_SNAPSHOT_BYTES
668                    );
669                }
670                let backup_relative = relative.clone();
671                let backup_path = backup_dir.join(&backup_relative);
672                if let Some(parent) = backup_path.parent() {
673                    tokio::fs::create_dir_all(parent).await?;
674                }
675                tokio::fs::copy(&path, &backup_path).await?;
676                files.push(IgnoredFile {
677                    relative,
678                    backup_relative: Some(backup_relative),
679                    symlink_target: None,
680                    #[cfg(unix)]
681                    mode: {
682                        use std::os::unix::fs::PermissionsExt;
683                        metadata.permissions().mode()
684                    },
685                });
686            }
687        }
688        Ok::<_, anyhow::Error>(IgnoredWorkspaceState {
689            backup_dir: backup_dir.clone(),
690            files,
691        })
692    }
693    .await;
694    if result.is_err() {
695        // The state object owns this directory only after successful capture.
696        // Clean partial snapshots on an early size, metadata, or copy error.
697        let _ = tokio::fs::remove_dir_all(&backup_dir).await;
698    }
699    result
700}
701
702async fn remove_workspace_path(path: &Path) -> anyhow::Result<()> {
703    let Ok(metadata) = tokio::fs::symlink_metadata(path).await else {
704        return Ok(());
705    };
706    if metadata.file_type().is_dir() {
707        tokio::fs::remove_dir_all(path).await?;
708    } else {
709        tokio::fs::remove_file(path).await?;
710    }
711    Ok(())
712}
713
714async fn restore_ignored_state(
715    workspace: &Path,
716    state: &IgnoredWorkspaceState,
717) -> anyhow::Result<()> {
718    // Restore tracked/untracked files separately; git clean intentionally does
719    // not touch ignored files, which is exactly where autoresearch configs,
720    // generated artifacts, and local credentials commonly live.
721    git_command(workspace, &["clean", "-fd"]).await?;
722
723    let baseline: HashSet<&Path> = state
724        .files
725        .iter()
726        .map(|file| file.relative.as_path())
727        .collect();
728    for relative in ignored_paths(workspace).await? {
729        if !baseline.contains(relative.as_path()) {
730            remove_workspace_path(&workspace.join(relative)).await?;
731        }
732    }
733
734    for file in &state.files {
735        let path = workspace.join(&file.relative);
736        if let Some(target) = &file.symlink_target {
737            remove_workspace_path(&path).await?;
738            if let Some(parent) = path.parent() {
739                tokio::fs::create_dir_all(parent).await?;
740            }
741            #[cfg(unix)]
742            std::os::unix::fs::symlink(target, &path)?;
743            #[cfg(windows)]
744            std::os::windows::fs::symlink_file(target, &path)?;
745        } else if let Some(backup_relative) = &file.backup_relative {
746            if let Some(parent) = path.parent() {
747                tokio::fs::create_dir_all(parent).await?;
748            }
749            if let Ok(metadata) = tokio::fs::symlink_metadata(&path).await {
750                if !metadata.file_type().is_file() {
751                    remove_workspace_path(&path).await?;
752                }
753            }
754            tokio::fs::copy(state.backup_dir.join(backup_relative), &path).await?;
755            #[cfg(unix)]
756            {
757                use std::os::unix::fs::PermissionsExt;
758                tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(file.mode))
759                    .await?;
760            }
761        }
762    }
763    Ok(())
764}
765
766async fn run_with_budget<T, F>(
767    started: Instant,
768    max_duration_secs: u64,
769    future: F,
770) -> anyhow::Result<T>
771where
772    F: Future<Output = anyhow::Result<T>>,
773{
774    if max_duration_secs == 0 {
775        return future.await;
776    }
777    let remaining = Duration::from_secs(max_duration_secs).saturating_sub(started.elapsed());
778    tokio::time::timeout(remaining, future)
779        .await
780        .with_context(|| WALL_CLOCK_BUDGET_EXHAUSTED)?
781}
782
783async fn save_ledger(path: &Path, ledger: &AutoresearchLedger) -> anyhow::Result<()> {
784    if let Some(parent) = path.parent() {
785        tokio::fs::create_dir_all(parent).await?;
786    }
787    let content = toml::to_string_pretty(ledger)?;
788    let temporary = path.with_extension("tmp");
789    tokio::fs::write(&temporary, content).await?;
790    replace_ledger_file(&temporary, path).await?;
791    Ok(())
792}
793
794#[cfg(not(windows))]
795async fn replace_ledger_file(temporary: &Path, destination: &Path) -> anyhow::Result<()> {
796    tokio::fs::rename(temporary, destination).await?;
797    Ok(())
798}
799
800#[cfg(windows)]
801async fn replace_ledger_file(temporary: &Path, destination: &Path) -> anyhow::Result<()> {
802    if !tokio::fs::try_exists(destination).await? {
803        tokio::fs::rename(temporary, destination).await?;
804        return Ok(());
805    }
806
807    let temporary = temporary.to_path_buf();
808    let destination = destination.to_path_buf();
809    tokio::task::spawn_blocking(move || windows_replace_file(&temporary, &destination)).await??;
810    Ok(())
811}
812
813#[cfg(windows)]
814fn windows_replace_file(temporary: &Path, destination: &Path) -> std::io::Result<()> {
815    use std::os::windows::ffi::OsStrExt;
816
817    #[link(name = "kernel32")]
818    extern "system" {
819        fn ReplaceFileW(
820            replaced_file_name: *const u16,
821            replacement_file_name: *const u16,
822            backup_file_name: *const u16,
823            replace_flags: u32,
824            exclude: *const std::ffi::c_void,
825            reserved: *const std::ffi::c_void,
826        ) -> i32;
827    }
828
829    let replaced = destination
830        .as_os_str()
831        .encode_wide()
832        .chain(std::iter::once(0))
833        .collect::<Vec<_>>();
834    let replacement = temporary
835        .as_os_str()
836        .encode_wide()
837        .chain(std::iter::once(0))
838        .collect::<Vec<_>>();
839    let succeeded = unsafe {
840        ReplaceFileW(
841            replaced.as_ptr(),
842            replacement.as_ptr(),
843            std::ptr::null(),
844            0,
845            std::ptr::null(),
846            std::ptr::null(),
847        )
848    };
849    if succeeded == 0 {
850        Err(std::io::Error::last_os_error())
851    } else {
852        Ok(())
853    }
854}
855
856async fn ensure_clean_workspace(workspace: &Path) -> anyhow::Result<()> {
857    let output = git_status(workspace).await?;
858    if !output.trim().is_empty() {
859        bail!("autoresearch requires a clean workspace; commit or stash existing changes first");
860    }
861    Ok(())
862}
863
864async fn git_status(workspace: &Path) -> anyhow::Result<String> {
865    git_command(workspace, &["status", "--porcelain"]).await
866}
867
868async fn ensure_status_unchanged(
869    workspace: &Path,
870    expected: &str,
871    source: &str,
872) -> anyhow::Result<()> {
873    let actual = git_status(workspace).await?;
874    if actual != expected {
875        bail!("{source} modified the tracked workspace; refusing to record its result");
876    }
877    Ok(())
878}
879
880async fn ensure_tracked_state_unchanged(
881    workspace: &Path,
882    expected_branch: &str,
883    expected_head: &str,
884) -> anyhow::Result<()> {
885    ensure_experiment_state(workspace, expected_branch, expected_head).await?;
886    let status = git_status(workspace).await?;
887    if !status.trim().is_empty() {
888        bail!("baseline command modified the workspace");
889    }
890    Ok(())
891}
892
893async fn restore_baseline_state(
894    workspace: &Path,
895    branch: &str,
896    commit: &str,
897    ignored_state: &IgnoredWorkspaceState,
898) -> anyhow::Result<()> {
899    ensure_experiment_state(workspace, branch, commit).await?;
900    restore_checkpoint(workspace, commit).await?;
901    restore_ignored_state(workspace, ignored_state).await
902}
903
904async fn ensure_ledger_path_safe(workspace: &Path, ledger_path: &Path) -> anyhow::Result<()> {
905    // `workspace` and the configured ledger can both be relative. Resolve
906    // them against the same current directory before canonicalizing existing
907    // ancestors; otherwise a relative workspace would be joined twice.
908    let current_dir = tokio::fs::canonicalize(".").await?;
909    let workspace_absolute = if workspace.is_absolute() {
910        workspace.to_path_buf()
911    } else {
912        current_dir.join(workspace)
913    };
914    let ledger_absolute = if ledger_path.is_absolute() {
915        ledger_path.to_path_buf()
916    } else {
917        current_dir.join(ledger_path)
918    };
919    let workspace = tokio::fs::canonicalize(&workspace_absolute)
920        .await
921        .with_context(|| format!("canonicalizing workspace {}", workspace.display()))?;
922    let ledger_path = normalize_path_for_containment(&ledger_absolute, &workspace).await?;
923    let Ok(relative) = ledger_path.strip_prefix(&workspace) else {
924        return Ok(());
925    };
926    let relative = relative.to_string_lossy();
927    let output = git_command(
928        &workspace,
929        &["check-ignore", "--quiet", "--", relative.as_ref()],
930    )
931    .await;
932    if output.is_err() {
933        bail!(
934            "autoresearch ledger path {} is inside the workspace but is not git-ignored; choose an ignored path or store the ledger outside the workspace",
935            ledger_path.display()
936        );
937    }
938    Ok(())
939}
940
941async fn normalize_path_for_containment(path: &Path, workspace: &Path) -> anyhow::Result<PathBuf> {
942    let candidate = if path.is_absolute() {
943        path.to_path_buf()
944    } else {
945        workspace.join(path)
946    };
947    if tokio::fs::try_exists(&candidate).await? {
948        return tokio::fs::canonicalize(&candidate)
949            .await
950            .map_err(Into::into);
951    }
952
953    let mut missing = Vec::new();
954    let mut existing = candidate.clone();
955    while !tokio::fs::try_exists(&existing).await? {
956        let Some(name) = existing.file_name() else {
957            bail!("cannot normalize path {}", path.display());
958        };
959        missing.push(name.to_os_string());
960        existing.pop();
961    }
962    let mut normalized = tokio::fs::canonicalize(existing).await?;
963    for component in missing.iter().rev() {
964        normalized.push(component);
965    }
966    Ok(normalized)
967}
968
969async fn git_rev(workspace: &Path) -> anyhow::Result<String> {
970    Ok(git_command(workspace, &["rev-parse", "HEAD"])
971        .await?
972        .trim()
973        .to_string())
974}
975
976async fn git_branch(workspace: &Path) -> anyhow::Result<String> {
977    Ok(git_command(workspace, &["branch", "--show-current"])
978        .await?
979        .trim()
980        .to_string())
981}
982
983async fn ensure_experiment_state(
984    workspace: &Path,
985    expected_branch: &str,
986    expected_head: &str,
987) -> anyhow::Result<()> {
988    let branch = git_branch(workspace).await?;
989    if branch != expected_branch {
990        bail!(
991            "autoresearch agent moved from branch `{expected_branch}` to `{branch}`; refusing to reset"
992        );
993    }
994    let head = git_rev(workspace).await?;
995    if head != expected_head {
996        bail!(
997            "autoresearch agent moved HEAD from `{expected_head}` to `{head}`; refusing to reset"
998        );
999    }
1000    Ok(())
1001}
1002
1003async fn commit_experiment(
1004    workspace: &Path,
1005    iteration: usize,
1006    started: Instant,
1007    max_duration_secs: u64,
1008) -> anyhow::Result<String> {
1009    git_command_with_budget(workspace, &["add", "-A"], started, max_duration_secs).await?;
1010    let status = git_command_with_budget(
1011        workspace,
1012        &["status", "--porcelain"],
1013        started,
1014        max_duration_secs,
1015    )
1016    .await?;
1017    if status.trim().is_empty() {
1018        bail!("experiment iteration {iteration} made no changes");
1019    }
1020    // Use a fresh empty hooks directory so repository-controlled hooks cannot
1021    // run during the controller's acceptance commit. `--no-verify` covers the
1022    // client-side pre-commit and commit-msg hooks as well.
1023    let hooks_dir = std::env::temp_dir().join(format!(
1024        "apollo-autoresearch-hooks-{}",
1025        uuid::Uuid::new_v4()
1026    ));
1027    tokio::fs::create_dir(&hooks_dir).await?;
1028    let hooks_path = hooks_dir.to_string_lossy().into_owned();
1029    let commit_result = git_command_with_budget(
1030        workspace,
1031        &[
1032            "-c",
1033            &format!("core.hooksPath={hooks_path}"),
1034            "commit",
1035            "--no-verify",
1036            "-m",
1037            &format!("autoresearch: iteration {iteration}"),
1038        ],
1039        started,
1040        max_duration_secs,
1041    )
1042    .await;
1043    let _ = tokio::fs::remove_dir(&hooks_dir).await;
1044    commit_result?;
1045    git_command_with_budget(
1046        workspace,
1047        &["rev-parse", "HEAD"],
1048        started,
1049        max_duration_secs,
1050    )
1051    .await
1052    .map(|commit| commit.trim().to_string())
1053}
1054
1055async fn restore_checkpoint(workspace: &Path, checkpoint: &str) -> anyhow::Result<()> {
1056    // The clean-workspace precondition makes these scoped resets recoverable:
1057    // only changes made by the rejected iteration can exist at this point.
1058    git_command(
1059        workspace,
1060        &["reset", "--hard", "--recurse-submodules", checkpoint],
1061    )
1062    .await?;
1063    git_command(
1064        workspace,
1065        &["submodule", "foreach", "--recursive", "git reset --hard"],
1066    )
1067    .await
1068    .or_else(|error| {
1069        if error.to_string().contains("no submodule") {
1070            Ok(String::new())
1071        } else {
1072            Err(error)
1073        }
1074    })?;
1075    git_command(
1076        workspace,
1077        &["submodule", "foreach", "--recursive", "git clean -fd"],
1078    )
1079    .await
1080    .or_else(|error| {
1081        if error.to_string().contains("no submodule") {
1082            Ok(String::new())
1083        } else {
1084            Err(error)
1085        }
1086    })?;
1087    git_command(workspace, &["clean", "-fd"]).await?;
1088    Ok(())
1089}
1090
1091async fn git_command(workspace: &Path, args: &[&str]) -> anyhow::Result<String> {
1092    git_command_with_timeout(workspace, args, None).await
1093}
1094
1095async fn git_command_with_budget(
1096    workspace: &Path,
1097    args: &[&str],
1098    started: Instant,
1099    max_duration_secs: u64,
1100) -> anyhow::Result<String> {
1101    let timeout = remaining_budget(started, max_duration_secs)?;
1102    git_command_with_timeout(workspace, args, timeout).await
1103}
1104
1105async fn git_command_with_timeout(
1106    workspace: &Path,
1107    args: &[&str],
1108    timeout: Option<Duration>,
1109) -> anyhow::Result<String> {
1110    let mut command = tokio::process::Command::new("git");
1111    command.args(args).current_dir(workspace);
1112    crate::tools::child_proc::scrub(&mut command);
1113    let output = run_process(&mut command, timeout, &format!("git {}", args.join(" "))).await?;
1114    if !output.status.success() {
1115        bail!(
1116            "git {} failed: {}",
1117            args.join(" "),
1118            crate::text::truncate_chars(&String::from_utf8_lossy(&output.stderr), 1000)
1119        );
1120    }
1121    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1122}
1123
1124async fn run_validation(
1125    config: &AutoresearchConfig,
1126    workspace: &Path,
1127    started: Instant,
1128    max_duration_secs: u64,
1129) -> anyhow::Result<bool> {
1130    if config.validation_command.trim().is_empty() {
1131        return Ok(true);
1132    }
1133    for attempt in 1..=config.validation_retries {
1134        let output = match run_shell(
1135            &config.validation_command,
1136            workspace,
1137            config.command_timeout_secs,
1138            started,
1139            max_duration_secs,
1140        )
1141        .await
1142        {
1143            Err(error) if is_budget_error(&error) => return Err(error),
1144            Ok(output) => output,
1145            Err(error) => {
1146                tracing::warn!(attempt, "autoresearch validation could not run: {error}");
1147                continue;
1148            }
1149        };
1150        if output.status.success() {
1151            return Ok(true);
1152        }
1153        tracing::warn!(
1154            attempt,
1155            "autoresearch validation failed: {}",
1156            crate::text::truncate_chars(&String::from_utf8_lossy(&output.stderr), 1000)
1157        );
1158    }
1159    Ok(false)
1160}
1161
1162async fn measure_metric(
1163    config: &AutoresearchConfig,
1164    workspace: &Path,
1165    started: Instant,
1166    max_duration_secs: u64,
1167) -> anyhow::Result<f64> {
1168    let mut values = Vec::with_capacity(config.samples);
1169    for _ in 0..config.samples {
1170        let output = run_shell(
1171            &config.metric_command,
1172            workspace,
1173            config.command_timeout_secs,
1174            started,
1175            max_duration_secs,
1176        )
1177        .await?;
1178        if !output.status.success() {
1179            bail!(
1180                "metric command failed: {}",
1181                crate::text::truncate_chars(&String::from_utf8_lossy(&output.stderr), 1000)
1182            );
1183        }
1184        values.push(parse_metric(&String::from_utf8_lossy(&output.stdout))?);
1185    }
1186    values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
1187    Ok(values[values.len() / 2])
1188}
1189
1190async fn run_shell(
1191    command: &str,
1192    workspace: &Path,
1193    timeout_secs: u64,
1194    started: Instant,
1195    max_duration_secs: u64,
1196) -> anyhow::Result<std::process::Output> {
1197    let command_timeout = Duration::from_secs(timeout_secs);
1198    let remaining = remaining_budget(started, max_duration_secs)?;
1199    let budget_is_tighter = remaining.is_some_and(|remaining| remaining <= command_timeout);
1200    let timeout = remaining
1201        .map(|remaining| remaining.min(command_timeout))
1202        .unwrap_or(command_timeout);
1203    let mut process = tokio::process::Command::new("sh");
1204    process.arg("-c").arg(command).current_dir(workspace);
1205    crate::tools::child_proc::scrub(&mut process);
1206    let label = if budget_is_tighter {
1207        WALL_CLOCK_BUDGET_EXHAUSTED
1208    } else {
1209        "autoresearch shell command"
1210    };
1211    run_process(&mut process, Some(timeout), label).await
1212}
1213
1214fn remaining_budget(started: Instant, max_duration_secs: u64) -> anyhow::Result<Option<Duration>> {
1215    if max_duration_secs == 0 {
1216        return Ok(None);
1217    }
1218    let remaining = Duration::from_secs(max_duration_secs).saturating_sub(started.elapsed());
1219    if remaining.is_zero() {
1220        bail!(WALL_CLOCK_BUDGET_EXHAUSTED);
1221    }
1222    Ok(Some(remaining))
1223}
1224
1225fn is_budget_error(error: &anyhow::Error) -> bool {
1226    error.to_string().contains(WALL_CLOCK_BUDGET_EXHAUSTED)
1227}
1228
1229fn configure_process_group(command: &mut tokio::process::Command) {
1230    #[cfg(unix)]
1231    {
1232        // A separate process group lets timeout cleanup terminate descendants
1233        // spawned by `sh -c`, such as cargo/compiler children.
1234        command.process_group(0);
1235    }
1236}
1237
1238fn terminate_process_group(pid: Option<u32>) {
1239    #[cfg(unix)]
1240    if let Some(pid) = pid {
1241        // SAFETY: pid is the process-group leader created by this command.
1242        unsafe {
1243            libc::killpg(pid as libc::pid_t, libc::SIGKILL);
1244        }
1245    }
1246}
1247
1248async fn run_process(
1249    command: &mut tokio::process::Command,
1250    timeout: Option<Duration>,
1251    label: &str,
1252) -> anyhow::Result<std::process::Output> {
1253    configure_process_group(command);
1254    command
1255        .stdout(std::process::Stdio::piped())
1256        .stderr(std::process::Stdio::piped());
1257    let child = command
1258        .kill_on_drop(true)
1259        .spawn()
1260        .with_context(|| format!("starting {label}"))?;
1261    let pid = child.id();
1262    let output = if let Some(timeout) = timeout {
1263        match tokio::time::timeout(timeout, child.wait_with_output()).await {
1264            Ok(result) => result.with_context(|| format!("waiting for {label}"))?,
1265            Err(_) => {
1266                terminate_process_group(pid);
1267                bail!("{label} timed out");
1268            }
1269        }
1270    } else {
1271        child
1272            .wait_with_output()
1273            .await
1274            .with_context(|| format!("waiting for {label}"))?
1275    };
1276    Ok(output)
1277}
1278
1279fn parse_metric(output: &str) -> anyhow::Result<f64> {
1280    output
1281        .split_whitespace()
1282        .find_map(|token| {
1283            token
1284                .trim_matches(|c: char| !c.is_ascii_digit() && c != '.' && c != '-')
1285                .parse::<f64>()
1286                .ok()
1287        })
1288        .filter(|value| value.is_finite())
1289        .ok_or_else(|| anyhow::anyhow!("metric command must print a numeric value"))
1290}
1291
1292fn is_better(config: &AutoresearchConfig, candidate: f64, current: f64) -> bool {
1293    let improvement = current.abs() * config.min_improvement_percent / 100.0;
1294    match config.direction.trim().to_ascii_lowercase().as_str() {
1295        "maximize" => candidate > current + improvement,
1296        _ => candidate < current - improvement,
1297    }
1298}
1299
1300fn percent_delta(previous: f64, candidate: f64) -> f64 {
1301    if previous == 0.0 {
1302        0.0
1303    } else {
1304        ((candidate - previous) / previous.abs()) * 100.0
1305    }
1306}
1307
1308fn first_line(text: &str) -> Option<String> {
1309    text.lines()
1310        .map(str::trim)
1311        .find(|line| !line.is_empty())
1312        .map(str::to_string)
1313}
1314
1315#[cfg(test)]
1316mod tests {
1317    use super::*;
1318
1319    #[test]
1320    fn parses_numeric_metric_from_command_output() {
1321        assert_eq!(parse_metric("median_ms=19.125\n").unwrap(), 19.125);
1322    }
1323
1324    #[test]
1325    fn median_sampling_is_sorted() {
1326        let mut values = [9.0, 1.0, 4.0];
1327        values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
1328        assert_eq!(values[values.len() / 2], 4.0);
1329    }
1330
1331    #[test]
1332    fn direction_and_tolerance_are_respected() {
1333        let config = AutoresearchConfig {
1334            direction: "minimize".into(),
1335            min_improvement_percent: 1.0,
1336            ..AutoresearchConfig::default()
1337        };
1338        assert!(is_better(&config, 98.9, 100.0));
1339        assert!(!is_better(&config, 100.5, 100.0));
1340        let strict = AutoresearchConfig::default();
1341        assert!(!is_better(&strict, 100.0, 100.0));
1342    }
1343
1344    #[test]
1345    fn experiment_definition_fingerprint_changes_when_metric_changes() {
1346        let config = AutoresearchConfig {
1347            objective: "startup".into(),
1348            metric_command: "./measure-a".into(),
1349            ..AutoresearchConfig::default()
1350        };
1351        let mut changed = config.clone();
1352        changed.metric_command = "./measure-b".into();
1353        assert_ne!(spec_fingerprint(&config), spec_fingerprint(&changed));
1354    }
1355
1356    #[test]
1357    fn new_ledger_is_bound_to_branch_and_has_a_unique_run_id() {
1358        let config = AutoresearchConfig {
1359            objective: "startup".into(),
1360            metric_command: "./measure".into(),
1361            ..AutoresearchConfig::default()
1362        };
1363        let first = AutoresearchLedger::new(&config, 10.0, "abc".into(), "feature/x".into());
1364        let second = AutoresearchLedger::new(&config, 10.0, "abc".into(), "feature/x".into());
1365        assert_eq!(first.branch, "feature/x");
1366        assert_eq!(first.spec_fingerprint, spec_fingerprint(&config));
1367        assert_ne!(first.chat_id, second.chat_id);
1368    }
1369
1370    #[tokio::test]
1371    async fn command_timeout_is_enforced() {
1372        let error = run_shell("sleep 5", Path::new("."), 1, Instant::now(), 0)
1373            .await
1374            .expect_err("long-running metric should time out");
1375        assert!(error.to_string().contains("timed out"));
1376    }
1377
1378    #[cfg(unix)]
1379    #[tokio::test]
1380    async fn command_timeout_terminates_shell_descendants() {
1381        let directory = tempfile::tempdir().unwrap();
1382        let marker = directory.path().join("descendant-finished");
1383        let command = format!(
1384            "sleep 2; touch {}",
1385            shlex::try_quote(&marker.to_string_lossy()).unwrap()
1386        );
1387        run_shell(&command, directory.path(), 1, Instant::now(), 0)
1388            .await
1389            .expect_err("command should time out");
1390        tokio::time::sleep(Duration::from_secs(2)).await;
1391        assert!(!marker.exists(), "timed-out descendant survived");
1392    }
1393
1394    #[cfg(unix)]
1395    #[tokio::test]
1396    async fn wall_clock_timeout_terminates_shell_descendants() {
1397        let directory = tempfile::tempdir().unwrap();
1398        let marker = directory.path().join("wall-clock-descendant-finished");
1399        let command = format!(
1400            "sleep 2; touch {}",
1401            shlex::try_quote(&marker.to_string_lossy()).unwrap()
1402        );
1403        let error = run_shell(&command, directory.path(), 60, Instant::now(), 1)
1404            .await
1405            .expect_err("wall-clock budget should time out the command");
1406        assert!(is_budget_error(&error));
1407        tokio::time::sleep(Duration::from_secs(2)).await;
1408        assert!(!marker.exists(), "wall-clock descendant survived");
1409    }
1410
1411    #[tokio::test]
1412    async fn ignored_state_restores_existing_files_and_removes_new_files() {
1413        let directory = tempfile::tempdir().unwrap();
1414        git_command(directory.path(), &["init", "-q"])
1415            .await
1416            .unwrap();
1417        tokio::fs::write(directory.path().join(".gitignore"), ".env\nnew-*\n")
1418            .await
1419            .unwrap();
1420        git_command(directory.path(), &["add", ".gitignore"])
1421            .await
1422            .unwrap();
1423        git_command(
1424            directory.path(),
1425            &[
1426                "-c",
1427                "user.name=Autoresearch Test",
1428                "-c",
1429                "user.email=autoresearch@example.invalid",
1430                "commit",
1431                "-qm",
1432                "initial",
1433            ],
1434        )
1435        .await
1436        .unwrap();
1437        tokio::fs::write(directory.path().join(".env"), "before\n")
1438            .await
1439            .unwrap();
1440
1441        let state = capture_ignored_state(directory.path()).await.unwrap();
1442        tokio::fs::write(directory.path().join(".env"), "candidate\n")
1443            .await
1444            .unwrap();
1445        tokio::fs::write(directory.path().join("new-output"), "candidate\n")
1446            .await
1447            .unwrap();
1448        restore_ignored_state(directory.path(), &state)
1449            .await
1450            .unwrap();
1451
1452        assert_eq!(
1453            tokio::fs::read_to_string(directory.path().join(".env"))
1454                .await
1455                .unwrap(),
1456            "before\n"
1457        );
1458        assert!(!directory.path().join("new-output").exists());
1459    }
1460
1461    #[tokio::test]
1462    async fn baseline_rejects_tracked_workspace_changes() {
1463        let directory = tempfile::tempdir().unwrap();
1464        git_command(directory.path(), &["init", "-q"])
1465            .await
1466            .unwrap();
1467        tokio::fs::write(directory.path().join("tracked.txt"), "before\n")
1468            .await
1469            .unwrap();
1470        git_command(directory.path(), &["add", "tracked.txt"])
1471            .await
1472            .unwrap();
1473        git_command(
1474            directory.path(),
1475            &[
1476                "-c",
1477                "user.name=Autoresearch Test",
1478                "-c",
1479                "user.email=autoresearch@example.invalid",
1480                "commit",
1481                "-qm",
1482                "initial",
1483            ],
1484        )
1485        .await
1486        .unwrap();
1487        let branch = git_branch(directory.path()).await.unwrap();
1488        let head = git_rev(directory.path()).await.unwrap();
1489        tokio::fs::write(directory.path().join("tracked.txt"), "changed\n")
1490            .await
1491            .unwrap();
1492
1493        let error = ensure_tracked_state_unchanged(directory.path(), &branch, &head)
1494            .await
1495            .expect_err("baseline must reject tracked changes");
1496        assert!(error.to_string().contains("baseline command modified"));
1497    }
1498
1499    #[tokio::test]
1500    async fn missing_path_is_normalized_before_workspace_containment_check() {
1501        let directory = tempfile::tempdir().unwrap();
1502        let workspace = tokio::fs::canonicalize(directory.path()).await.unwrap();
1503        let ledger = workspace.join(".apollo").join("ledger.toml");
1504        let normalized = normalize_path_for_containment(&ledger, &workspace)
1505            .await
1506            .unwrap();
1507        assert!(normalized.starts_with(&workspace));
1508        assert_eq!(normalized, ledger);
1509    }
1510
1511    #[tokio::test]
1512    async fn validation_timeout_is_a_rejected_attempt() {
1513        let config = AutoresearchConfig {
1514            validation_command: "sleep 5".into(),
1515            validation_retries: 1,
1516            command_timeout_secs: 1,
1517            ..AutoresearchConfig::default()
1518        };
1519        assert!(!run_validation(&config, Path::new("."), Instant::now(), 0)
1520            .await
1521            .unwrap());
1522    }
1523}