use std::collections::BTreeSet;
use std::path::{Component, Path, PathBuf};
use globset::Glob;
use argonauts_core::events::RunEventKind;
use argonauts_core::events::session::InputPart;
use argonauts_core::events::task::TaskEvent;
use argonauts_core::layout;
use argonauts_core::names::{ModelRef, SessionKey};
use argonauts_core::paths::{create_private_dir_all, write_private_file};
use argonauts_core::{CommitInfo, DiffStat, Note, NoteLevel, RunId, TaskId};
use crate::engine::host::Host;
use crate::engine::shared::lock;
use crate::error::{EngineError, EngineResult};
use crate::fallback::notes;
use crate::git::{Git, LeftOut, LeftOutWhy};
use crate::idempotency;
use crate::merge::branch::{ensure_run_branch, run_branch, task_branch, try_branch};
use crate::merge::conductor;
use crate::task::claims;
use crate::task::prompt;
use crate::task::support::{record_of, record_task, record_task_in};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskTree {
pub path: PathBuf,
pub branch: String,
pub base: String,
}
pub async fn ensure(host: &Host, task: &TaskId) -> EngineResult<TaskTree> {
let record = record_of(host, task)?;
let branch = record
.branch
.clone()
.unwrap_or_else(|| task_branch(host.run_id(), task));
let path = record
.worktree
.clone()
.unwrap_or_else(|| host.repo().worktrees.join(task.as_str()));
let base = match &record.base {
Some(base) => base.clone(),
None => run_head(host).await?,
};
let tree = TaskTree { path, branch, base };
let fresh = make_tree(host, &tree, &tree.base).await?;
let recorded = record.worktree.as_ref() == Some(&tree.path)
&& record.branch.as_ref() == Some(&tree.branch)
&& record.base.as_ref() == Some(&tree.base);
if !recorded {
let event = TaskEvent::Worktree {
path: tree.path.clone(),
branch: tree.branch.clone(),
base: tree.base.clone(),
};
record_task(host, task, event)?;
}
if fresh {
copy_untracked(host, &tree.path).await?;
}
Ok(tree)
}
pub async fn try_tree(host: &Host, task: &TaskId, try_number: u32) -> EngineResult<TaskTree> {
let own = ensure(host, task).await?;
let start = host
.git()
.branch_head(&host.repo().root, &own.branch)
.await?
.unwrap_or_else(|| own.base.clone());
let tree = TaskTree {
path: try_path(host, task, try_number),
branch: try_branch(host.run_id(), task, try_number),
base: own.base,
};
if make_tree(host, &tree, &start).await? {
copy_untracked(host, &tree.path).await?;
}
Ok(tree)
}
pub async fn try_taken(host: &Host, task: &TaskId, try_number: u32) -> EngineResult<bool> {
let path = try_path(host, task, try_number);
if path.exists() {
return Ok(true);
}
let branch = try_branch(host.run_id(), task, try_number);
Ok(host
.git()
.branch_head(&host.repo().root, &branch)
.await?
.is_some())
}
pub fn try_of_branch(run: &RunId, task: &TaskId, branch: &str) -> Option<u32> {
let prefix = format!("{}-try-", task_branch(run, task));
branch.strip_prefix(&prefix)?.parse().ok()
}
pub fn try_of_tree(task: &TaskId, tree: &Path) -> Option<u32> {
let name = tree.file_name()?.to_str()?;
name.strip_prefix(&format!("{task}-try-"))?.parse().ok()
}
fn try_path(host: &Host, task: &TaskId, try_number: u32) -> PathBuf {
host.repo()
.worktrees
.join(format!("{task}-try-{try_number}"))
}
pub async fn commit_after_end(
host: &Host,
task: &TaskId,
session: &SessionKey,
) -> EngineResult<Option<CommitInfo>> {
let tree = ensure(host, task).await?;
if tree_git(host, &tree.path)
.await?
.merge_in_progress(&tree.path)
.await?
{
return Ok(None);
}
let worker = host.read(|state| {
state
.sessions
.get(session)
.map(|record| record.started.worker.clone())
});
commit_in(host, task, &tree, Some(session), worker).await
}
pub(crate) async fn commit_in(
host: &Host,
task: &TaskId,
tree: &TaskTree,
session: Option<&SessionKey>,
by: Option<ModelRef>,
) -> EngineResult<Option<CommitInfo>> {
let git = tree_git(host, &tree.path).await?;
let session = session.map(|key| {
let start = host.read(|state| state.sessions.get(key).map_or(1, |record| record.starts));
(key, start)
});
if let Some((key, start)) = session
&& commit_recorded(host, task, key, start)?
&& git.is_clean(&tree.path).await?
{
return Ok(None);
}
let range = format!("{}..HEAD", tree.base);
let mut tree = tree.clone();
let mut made = Vec::new();
let mut own = Vec::new();
if by.is_some() {
let key = session.map(|(key, _)| key);
for sha in agents_commits(host, task, &git, &tree.path, &range, session).await? {
let (info, moved) =
record_commit(host, task, &git, &tree, &sha, key, by.as_ref()).await?;
tree = moved;
made.push(info);
own.push(sha);
}
}
let existing = match session {
Some((key, start)) => {
idempotency::commit_for_session(&git, &tree.path, &range, key, start).await?
}
None => None,
};
let leftover = match existing {
Some(sha) if record_of(host, task)?.has_commit(&sha) => None,
Some(sha) => Some(sha),
None => {
let message = commit_message(host.run_id(), task, session, by.as_ref());
let claims = by
.as_ref()
.and_then(|_| claims::of_task(host, task, &tree.path));
let committed = if git.merge_in_progress(&tree.path).await? {
if !git.leftover_markers(&tree.path).await?.is_empty() {
return finish(host, task, &git, &tree, made).await;
}
let (sha, left_out) = git
.finish_merge_work(&tree.path, &message, claims.as_ref())
.await?;
tell_left_out(host, task, &left_out)?;
Some(sha)
} else {
git.unstage_left_out(&tree.path, &own, claims.as_ref())
.await?;
let committed = git
.commit_claimed(&tree.path, &message, claims.as_ref())
.await?;
tell_left_out(host, task, &committed.left_out)?;
committed.sha
};
if let (None, Some((key, _))) = (&committed, session) {
let nothing = CommitInfo {
branch: tree.branch.clone(),
sha: None,
message,
stat: DiffStat::default(),
by: by.clone(),
};
record_task_in(host, task, key, TaskEvent::Committed(nothing))?;
}
committed
}
};
if let Some(sha) = leftover {
let key = session.map(|(key, _)| key);
let (info, moved) = record_commit(host, task, &git, &tree, &sha, key, by.as_ref()).await?;
tree = moved;
made.push(info);
}
finish(host, task, &git, &tree, made).await
}
const LEFT_OUT_NAMED: usize = 6;
fn tell_left_out(host: &Host, task: &TaskId, left_out: &[LeftOut]) -> EngineResult<()> {
for why in [
LeftOutWhy::BuildOutput,
LeftOutWhy::Unclaimed,
LeftOutWhy::TooBig,
LeftOutWhy::Ignored,
] {
let new: Vec<&str> = {
let mut told = lock(&host.shared().told_left_out);
left_out
.iter()
.filter(|left| left.why == why)
.filter(|left| told.insert(format!("{task}:{}", left.because)))
.map(|left| left.because.as_str())
.collect()
};
if new.is_empty() {
continue;
}
let shown = new.len().min(LEFT_OUT_NAMED);
let mut named = new[..shown].join(", ");
if new.len() > shown {
named.push_str(&format!(" and {} more", new.len() - shown));
}
let text = match why {
LeftOutWhy::BuildOutput => format!(
"Left out of {task}'s commit as build and cache output, and kept in its \
worktree: {named}. merge.leave_out and merge.leave_out_builtin set what's \
left out."
),
LeftOutWhy::Unclaimed => format!(
"Left out of {task}'s commit, and kept in its worktree: {named}. No edit tool \
wrote them and no report lists them, so they read as something a command \
left behind. commit.unclaimed = \"keep\" commits every new file."
),
LeftOutWhy::TooBig => format!(
"Left out of {task}'s commit as bigger than commit.max_new_file_mb allows, \
and kept in its worktree: {named}."
),
LeftOutWhy::Ignored => format!(
"Left out of {task}'s commit because the repository's ignore rules match \
them, and kept in its worktree: {named}. A file that belongs in the change \
needs another name, or a change to the rule."
),
};
host.record(
Some(task),
None,
RunEventKind::Note(Note {
level: NoteLevel::Info,
text,
}),
)?;
}
Ok(())
}
pub(crate) async fn left_out_part(host: &Host, task: &TaskId) -> EngineResult<Option<InputPart>> {
let tree = ensure(host, task).await?;
let Some(claims) = claims::of_task(host, task, &tree.path) else {
return Ok(None);
};
let git = tree_git(host, &tree.path).await?;
let mut text = Vec::new();
if let Some(named) = named(&git.unclaimed_files(&tree.path, &claims).await?) {
text.push(format!(
"These new files in your worktree are not in your commit, because no edit tool \
wrote them and your report didn't list them: {named}. If the change needs one of \
them, list it in your report's files_changed. Leave caches, build output and \
test leftovers out."
));
}
if let Some(named) = named(&git.claimed_but_ignored(&tree.path, &claims).await?) {
text.push(format!(
"These files you meant to create are not in your commit, because the \
repository's ignore rules match them: {named}. If the change needs one, give it \
another name, or change the rule in an edit of the .gitignore file."
));
}
if text.is_empty() {
return Ok(None);
}
Ok(Some(prompt::message_part(
"left out of your commit",
text.join("\n\n"),
)))
}
fn named(left_out: &[LeftOut]) -> Option<String> {
let mut named: Vec<&str> = left_out.iter().map(|left| left.because.as_str()).collect();
named.dedup();
if named.is_empty() {
return None;
}
let more = named.len().saturating_sub(LEFT_OUT_NAMED);
named.truncate(LEFT_OUT_NAMED);
let mut text = named.join(", ");
if more > 0 {
text.push_str(&format!(" and {more} more"));
}
Some(text)
}
async fn agents_commits(
host: &Host,
task: &TaskId,
git: &Git,
dir: &Path,
range: &str,
session: Option<(&SessionKey, u32)>,
) -> EngineResult<Vec<String>> {
let record = record_of(host, task)?;
let mut shas: Vec<String> = git
.first_parent_log(dir, range)
.await?
.into_iter()
.filter(|(sha, message)| {
let ours =
session.is_some_and(|(key, start)| idempotency::holds_start(message, key, start));
!record.has_commit(sha) && !ours
})
.map(|(sha, _)| sha)
.collect();
shas.reverse();
Ok(shas)
}
async fn record_commit(
host: &Host,
task: &TaskId,
git: &Git,
tree: &TaskTree,
sha: &str,
session: Option<&SessionKey>,
by: Option<&ModelRef>,
) -> EngineResult<(CommitInfo, TaskTree)> {
let message = git
.log_messages(&tree.path, &format!("{sha}^!"))
.await?
.into_iter()
.next()
.map(|(_, message)| message)
.unwrap_or_default();
let parents = git.parents(&tree.path, sha).await?;
let stat = match parents.first() {
Some(parent) => git.diff_stat(&tree.path, parent, sha).await?,
None => Default::default(),
};
let info = CommitInfo {
branch: tree.branch.clone(),
sha: Some(sha.to_owned()),
message,
stat,
by: by.cloned(),
};
let committed = TaskEvent::Committed(info.clone());
match session {
Some(key) => record_task_in(host, task, key, committed)?,
None => record_task(host, task, committed)?,
};
let tree = rebase_on_merge(host, task, git, tree, &parents).await?;
Ok((info, tree))
}
async fn finish(
host: &Host,
task: &TaskId,
git: &Git,
tree: &TaskTree,
mut made: Vec<CommitInfo>,
) -> EngineResult<Option<CommitInfo>> {
let root = host.repo().root.clone();
for info in &made {
if let (Some(sha), Some(author)) = (&info.sha, &info.by) {
notes::record_author(host, &root, sha, author).await?;
}
}
let Some(newest) = made.pop() else {
return Ok(None);
};
if let Some(sha) = &newest.sha {
save_diff(host, task, git, tree, sha).await?;
}
Ok(Some(newest))
}
async fn rebase_on_merge(
host: &Host,
task: &TaskId,
git: &Git,
tree: &TaskTree,
parents: &[String],
) -> EngineResult<TaskTree> {
let [_, merged] = parents else {
return Ok(tree.clone());
};
let own = record_of(host, task)?.branch.as_ref() == Some(&tree.branch);
if !own || *merged == tree.base {
return Ok(tree.clone());
}
let run_branch = run_branch(host.run_id());
let Some(head) = git.branch_head(&tree.path, &run_branch).await? else {
return Ok(tree.clone());
};
let forward = git.is_ancestor(&tree.path, merged, &head).await?
&& git.is_ancestor(&tree.path, &tree.base, merged).await?;
if !forward {
return Ok(tree.clone());
}
let moved = TaskTree {
base: merged.clone(),
..tree.clone()
};
let event = TaskEvent::Worktree {
path: moved.path.clone(),
branch: moved.branch.clone(),
base: moved.base.clone(),
};
record_task(host, task, event)?;
Ok(moved)
}
pub(crate) fn conductor_commits(host: &Host, task: &TaskId) -> EngineResult<BTreeSet<String>> {
Ok(record_of(host, task)?
.commits
.iter()
.filter(|commit| commit.by.is_none())
.filter_map(|commit| commit.sha.clone())
.collect())
}
fn commit_recorded(
host: &Host,
task: &TaskId,
session: &SessionKey,
start: u32,
) -> EngineResult<bool> {
Ok(record_of(host, task)?
.commits
.iter()
.any(|commit| idempotency::holds_start(&commit.message, session, start)))
}
pub(crate) async fn save_diff(
host: &Host,
task: &TaskId,
git: &Git,
tree: &TaskTree,
head: &str,
) -> EngineResult<()> {
let text = git.diff(&tree.path, &tree.base, head).await?;
let stat = git.diff_stat(&tree.path, &tree.base, head).await?;
let dir = layout::task_dir(&host.repo().run_dir, task).join("diffs");
create_private_dir_all(&dir)?;
let short: String = head.chars().take(12).collect();
let path = dir.join(format!("{short}.diff"));
write_private_file(&path, text.as_bytes())?;
record_task(host, task, TaskEvent::Diff { stat, path })?;
Ok(())
}
pub(crate) async fn tree_git(host: &Host, tree: &Path) -> EngineResult<Git> {
let git = host.git();
match git.worktree_git_dir(&host.repo().root, tree).await? {
Some(admin) => Ok(git.bound_to(admin, tree.to_path_buf())),
None => Err(EngineError::Refused(format!(
"{} is not a worktree of this repository",
tree.display()
))),
}
}
fn commit_message(
run: &RunId,
task: &TaskId,
session: Option<(&SessionKey, u32)>,
by: Option<&ModelRef>,
) -> String {
match (session, by) {
(Some((session, start)), Some(by)) => format!(
"{task}: work from {session} ({by})\n\n{}",
idempotency::session_lines(session, start)
),
(Some((session, start)), None) => format!(
"{task}: work from {session}\n\n{}",
idempotency::session_lines(session, start)
),
(None, Some(by)) => format!("{task}: work left by {by}"),
(None, None) => conductor::message(run, &format!("{task}: your changes")),
}
}
async fn run_head(host: &Host) -> EngineResult<String> {
ensure_run_branch(host).await?;
let branch = run_branch(host.run_id());
host.git()
.branch_head(&host.repo().root, &branch)
.await?
.ok_or_else(|| EngineError::Refused(format!("{branch} has no commits")))
}
async fn make_tree(host: &Host, tree: &TaskTree, start: &str) -> EngineResult<bool> {
if tree.path.join(".git").exists() {
return Ok(false);
}
if let Some(parent) = tree.path.parent() {
std::fs::create_dir_all(parent)?;
}
let git = host.git();
let root = &host.repo().root;
if git.branch_head(root, &tree.branch).await?.is_some() {
git.add_worktree_on(root, &tree.path, &tree.branch).await?;
} else {
git.add_worktree(root, &tree.path, &tree.branch, start)
.await?;
}
Ok(true)
}
async fn copy_untracked(host: &Host, tree: &Path) -> EngineResult<()> {
let patterns = &host.settings().checks.copy_untracked;
if patterns.is_empty() {
return Ok(());
}
let root = &host.repo().root;
let untracked = host.git().untracked_files(root).await?;
for rel in files_to_copy(patterns, &untracked, root) {
let (from, to) = (root.join(&rel), tree.join(&rel));
if to.exists() || !from.is_file() {
continue;
}
if let Some(parent) = to.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&from, &to)?;
}
Ok(())
}
fn files_to_copy(patterns: &[String], untracked: &[String], root: &Path) -> Vec<String> {
let mut picked = Vec::new();
for pattern in patterns {
let is_glob = pattern.contains(['*', '?', '[', '{']);
if !is_glob {
if is_inside(pattern) && root.join(pattern).is_file() {
picked.push(pattern.clone());
}
continue;
}
let Ok(glob) = Glob::new(pattern) else {
continue;
};
let matcher = glob.compile_matcher();
picked.extend(
untracked
.iter()
.filter(|path| is_inside(path) && matcher.is_match(path.as_str()))
.cloned(),
);
}
picked.sort();
picked.dedup();
picked
}
fn is_inside(path: &str) -> bool {
Path::new(path)
.components()
.all(|part| matches!(part, Component::Normal(_) | Component::CurDir))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn copy_untracked_takes_plain_paths_and_globs_inside_the_repo() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join(".env.test"), "A=1").unwrap();
let patterns = vec![
".env.test".to_owned(),
"fixtures/*.json".to_owned(),
"../outside".to_owned(),
"missing.txt".to_owned(),
];
let untracked = vec![
"fixtures/a.json".to_owned(),
"fixtures/b.txt".to_owned(),
"../escape.json".to_owned(),
];
assert_eq!(
files_to_copy(&patterns, &untracked, root),
[".env.test", "fixtures/a.json"]
);
}
#[test]
fn try_numbers_come_from_tree_and_branch_names() {
let run = RunId::new("run-01").unwrap();
let task = TaskId::new("task-002").unwrap();
let branch = try_branch(&run, &task, 7);
assert_eq!(try_of_branch(&run, &task, &branch), Some(7));
assert_eq!(try_of_branch(&run, &task, &task_branch(&run, &task)), None);
let other = TaskId::new("task-003").unwrap();
assert_eq!(try_of_branch(&run, &other, &branch), None);
let tree = Path::new("/worktrees/run-01/task-002-try-3");
assert_eq!(try_of_tree(&task, tree), Some(3));
assert_eq!(try_of_tree(&other, tree), None);
assert_eq!(try_of_tree(&task, Path::new("/worktrees/task-002")), None);
}
#[test]
fn commit_messages_carry_the_session_key() {
let run = RunId::new("run-01").unwrap();
let task = TaskId::new("task-002").unwrap();
let key = SessionKey::numbered(4);
let by: ModelRef = "codex".parse().unwrap();
let text = commit_message(&run, &task, Some((&key, 1)), Some(&by));
assert_eq!(
text,
"task-002: work from s-004 (codex)\n\nArgonauts-Session: s-004"
);
let resumed = commit_message(&run, &task, Some((&key, 2)), Some(&by));
assert!(resumed.ends_with("Argonauts-Session: s-004\nArgonauts-Start: 2"));
let yours = commit_message(&run, &task, None, None);
assert!(conductor::is_conductor_message(&run, &yours), "{yours}");
let left = commit_message(&run, &task, None, Some(&by));
assert!(!conductor::is_conductor_message(&run, &left), "{left}");
}
}