1use 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 pub objective: String,
32 pub metric_command: String,
34 pub direction: String,
36 pub validation_command: String,
38 pub validation_retries: usize,
40 pub command_timeout_secs: u64,
42 pub samples: usize,
44 pub min_improvement_percent: f64,
46 pub max_iterations: usize,
48 pub max_duration_secs: u64,
50 pub ledger_path: String,
52 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 #[serde(default)]
156 pub branch: String,
157 #[serde(default)]
159 pub chat_id: String,
160 #[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
191pub 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 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 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 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 .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 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 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 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 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 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 command.process_group(0);
1235 }
1236}
1237
1238fn terminate_process_group(pid: Option<u32>) {
1239 #[cfg(unix)]
1240 if let Some(pid) = pid {
1241 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}