Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 70 additions & 11 deletions src/daemon/trace_normalizer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -965,7 +965,7 @@ impl<B: GitBackend> TraceNormalizer<B> {
}

let mut resolved = false;
let mut last_error: Option<(PathBuf, AutterError)> = None;
let mut last_failed_target: Option<PathBuf> = None;
for candidate in candidates {
if let Some(common_dir) = common_dir_for_repo_path(&candidate) {
let resolved_family = FamilyKey::new(
Expand All @@ -981,13 +981,7 @@ impl<B: GitBackend> TraceNormalizer<B> {
resolved = true;
break;
} else {
last_error = Some((
candidate.clone(),
AutterError::Generic(format!(
"failed to resolve clone/init target family from filesystem: {}",
candidate.display()
)),
));
last_failed_target = Some(candidate.clone());
}
}

Expand All @@ -996,13 +990,23 @@ impl<B: GitBackend> TraceNormalizer<B> {
if let Some(target) = target_from_def_repo.or(target_from_argv) {
pending.worktree = Some(target);
}
if let Some((target, error)) = last_error {
observability::log_error(
&error,
if let Some(target) = last_failed_target {
// A clone/init target that no longer exists at finalize is an
// expected race, not a bug: the directory (an agent plugin
// staging clone, or a throwaway test repo) was removed the moment
// git exited. The best worktree hint is already kept above, so
// attribution is unaffected. Log below error level, the same way
// the vanished-working-dir guard above does, and keep the
// absolute path in structured context so occurrences group into
// one issue instead of one per directory.
observability::log_message(
"clone/init target family unresolved at finalize; target directory removed before daemon finalized",
"warn",
Some(serde_json::json!({
"component": "trace_normalizer",
"phase": "resolve_clone_or_init_target_family",
"root_sid": pending.root_sid,
"primary_command": primary_command,
"target": target,
})),
);
Expand Down Expand Up @@ -2109,6 +2113,61 @@ mod tests {
assert!(matches!(cmd.scope, CommandScope::Family(_)));
}

#[test]
fn clone_target_vanished_before_finalize_keeps_worktree_hint_and_does_not_error() {
// The clone target directory is removed before the daemon finalizes the
// trace (an expected race for staging clones and throwaway test repos).
// Family resolution fails, but finalize must still succeed, keep the best
// worktree hint, and fall back to Global scope rather than erroring.
let backend = Arc::new(MockBackend::default());
let mut normalizer = TraceNormalizer::new(backend);
let temp = tempfile::tempdir().expect("create tempdir");
let outer = temp.path().join("outer");
let clone_dir = outer.join("nested").join("vanished-clone");
fs::create_dir_all(clone_dir.parent().expect("clone parent")).expect("create clone parent");

let start = serde_json::json!({
"event":"start",
"sid":"clone-vanished",
"ts":1,
"argv":["git","clone","ssh://example/repo.git","nested/vanished-clone"],
"worktree":outer
});
let def_repo = serde_json::json!({
"event":"def_repo",
"sid":"clone-vanished",
"ts":2,
"worktree":clone_dir
});
let cmd_name = serde_json::json!({
"event":"cmd_name",
"sid":"clone-vanished",
"ts":3,
"name":"clone"
});
let exit = serde_json::json!({
"event":"exit",
"sid":"clone-vanished",
"ts":4,
"code":0
});

assert!(normalizer.ingest_payload(&start).unwrap().is_none());
assert!(normalizer.ingest_payload(&def_repo).unwrap().is_none());
assert!(normalizer.ingest_payload(&cmd_name).unwrap().is_none());

// The target repo never materializes on disk, mirroring a directory that
// vanished the moment git exited.
let cmd = normalizer
.ingest_payload(&exit)
.expect("clone finalize should not error when the target vanished")
.expect("clone should still emit a normalized command");

assert_eq!(cmd.primary_command.as_deref(), Some("clone"));
assert_eq!(cmd.worktree.as_ref(), Some(&clone_dir));
assert!(matches!(cmd.scope, CommandScope::Global));
}

#[test]
fn clone_prefers_target_family_over_source_cwd_family() {
let backend = Arc::new(MockBackend::default());
Expand Down
Loading