mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
fix(subagent): clean up refused-spawn worktrees off the async worker, despite hook output
- A refused spawn's worktree is removed on the blocking pool instead of inline in Drop on a tokio worker (git status over the checkout, git worktree remove and a directory delete). A failed removal now logs at warn instead of debug. - The rollback no longer requires a pristine `git status`. No child ever ran in that checkout, so its content came from the checkout itself; post-checkout hooks, line-ending filters or LFS left files there and every refused spawn in such repos kept its worktree. Removal still deletes only a path git lists as a linked worktree, and the branch only when merged. - An explicit worktree_branch whose 48-character path slug had to be cut gets a short hash of the full branch name, so long branches sharing a prefix no longer map to one checkout. - The resume refusal for a missing workspace states what was observed and gives the removed worktree as one example, not the only cause. Tests: 2 new regressions (hook output rollback; long explicit branches), both fail with the fixes switched off and pass with them. Targeted Rust: tools::subagent::worktree 5/0, worktree 47/0, failed_spawn and continuation tests 3/0, resume 145/0, follow_up 7/0. cargo fmt --check pass; clippy (CI flags) clean in touched files. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_014ZwqatxgVFxHvovngywnks
This commit is contained in:
1 parent
24ede12e22
commit
b43625aac5
3 files changed
+130
-20
No files matched your search
@@ -6921,12 +6921,13 @@ impl SubAgentManager {
|
||||
runtime.max_spawn_depth
|
||||
));
|
||||
}
|
||||
// A finished worker's unchanged isolated worktree is removed with its
|
||||
// branch, but its checkpoint stays continuable. Resuming into the
|
||||
// missing directory would start a child whose every tool fails.
|
||||
// A checkpoint outlives its workspace (a finished worker's unchanged
|
||||
// worktree is removed, a directory is deleted or unmounted).
|
||||
// Resuming into the missing directory would start a child whose
|
||||
// every tool fails.
|
||||
if !workspace.is_dir() {
|
||||
return Err(anyhow!(
|
||||
"Cannot resume agent {agent_id}: its workspace {} no longer exists (an isolated worktree with no changes is removed when the agent finishes); start a new agent for the follow-up work.",
|
||||
"Cannot resume agent {agent_id}: its workspace {} no longer exists (for example, an isolated worktree with no changes is removed when its agent finishes); start a new agent for the follow-up work.",
|
||||
workspace.display()
|
||||
));
|
||||
}
|
||||
@@ -11495,20 +11496,22 @@ async fn spawn_subagent_from_input(
|
||||
}
|
||||
|
||||
/// An isolated worktree created for a spawn that has not started yet. If the
|
||||
/// spawn fails it is removed with its branch, through the same path that
|
||||
/// removes a finished worker's unchanged checkout, so nothing the worktree
|
||||
/// holds beyond its fresh checkout is ever deleted.
|
||||
/// spawn fails, or its future is dropped, the checkout and its new branch are
|
||||
/// removed. Removal runs git and deletes a directory, so inside a runtime it
|
||||
/// goes to the blocking pool rather than stall an async worker.
|
||||
struct PendingChildWorktree(Option<PathBuf>);
|
||||
|
||||
impl Drop for PendingChildWorktree {
|
||||
fn drop(&mut self) {
|
||||
if let Some(worktree) = self.0.take()
|
||||
&& !worktree::remove_unchanged_worktree(&worktree, Some(&BTreeSet::new()))
|
||||
{
|
||||
tracing::debug!(
|
||||
"kept the worktree of a failed sub-agent spawn: {}",
|
||||
worktree.display()
|
||||
);
|
||||
let Some(worktree) = self.0.take() else {
|
||||
return;
|
||||
};
|
||||
let remove = move || worktree::remove_unstarted_worktree(&worktree);
|
||||
match tokio::runtime::Handle::try_current() {
|
||||
Ok(runtime) => {
|
||||
runtime.spawn_blocking(remove);
|
||||
}
|
||||
Err(_) => remove(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23051,6 +23051,25 @@ async fn continuing_a_child_whose_worktree_was_removed_fails_with_the_reason() {
|
||||
#[tokio::test]
|
||||
async fn failed_spawn_removes_the_worktree_it_created() {
|
||||
let repo = init_subagent_git_repo();
|
||||
refuse_worktree_spawn_and_expect_cleanup(&repo, "codex/agent-rollback-probe").await;
|
||||
}
|
||||
|
||||
/// Post-checkout hooks (and line-ending or LFS filters) can leave files in a
|
||||
/// fresh checkout. The rollback used to require a pristine `git status`, so
|
||||
/// in such repos every refused spawn still left its worktree behind.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn failed_spawn_removes_its_worktree_despite_post_checkout_hook_output() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let repo = init_subagent_git_repo();
|
||||
let hook = repo.path().join(".git").join("hooks").join("post-checkout");
|
||||
std::fs::create_dir_all(hook.parent().unwrap()).expect("hooks dir");
|
||||
std::fs::write(&hook, "#!/bin/sh\necho generated > hook-output.txt\n").expect("hook");
|
||||
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).expect("chmod");
|
||||
refuse_worktree_spawn_and_expect_cleanup(&repo, "codex/agent-hook-probe").await;
|
||||
}
|
||||
|
||||
async fn refuse_worktree_spawn_and_expect_cleanup(repo: &tempfile::TempDir, branch: &str) {
|
||||
let mut runtime = stub_runtime();
|
||||
runtime.context = ToolContext::new(repo.path().to_path_buf());
|
||||
let manager = new_shared_subagent_manager(repo.path().to_path_buf(), 4);
|
||||
@@ -23060,14 +23079,14 @@ async fn failed_spawn_removes_the_worktree_it_created() {
|
||||
assign_test_session_owner(&mut guard, &source, &runtime.context.state_namespace);
|
||||
source
|
||||
};
|
||||
let branch = "codex/agent-rollback-probe";
|
||||
let worktree_path = format!("{}-path", branch.replace('/', "-"));
|
||||
let err = spawn_subagent_from_input(
|
||||
json!({
|
||||
"prompt": "continue the review",
|
||||
"type": "scout",
|
||||
"worktree": true,
|
||||
"worktree_branch": branch,
|
||||
"worktree_path": "rollback-probe",
|
||||
"worktree_path": worktree_path,
|
||||
"resume_from": source,
|
||||
}),
|
||||
Arc::clone(&manager),
|
||||
@@ -23079,7 +23098,20 @@ async fn failed_spawn_removes_the_worktree_it_created() {
|
||||
.expect_err("a running resume source refuses the spawn");
|
||||
assert!(err.to_string().contains("still running"), "{err}");
|
||||
|
||||
let worktrees = git_stdout(repo.path(), &["worktree", "list", "--porcelain"]);
|
||||
// The removal runs on the blocking pool, off the async worker.
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(30);
|
||||
let worktrees = loop {
|
||||
let worktrees = git_stdout(repo.path(), &["worktree", "list", "--porcelain"]);
|
||||
let count = worktrees
|
||||
.lines()
|
||||
.filter(|line| line.starts_with("worktree "))
|
||||
.count();
|
||||
let branch_gone = git_stdout(repo.path(), &["branch", "--list", branch]).is_empty();
|
||||
if (count == 1 && branch_gone) || std::time::Instant::now() >= deadline {
|
||||
break worktrees;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
};
|
||||
assert_eq!(
|
||||
worktrees
|
||||
.lines()
|
||||
|
||||
@@ -159,6 +159,28 @@ pub(super) fn remove_unchanged_worktree(
|
||||
!worktree.exists()
|
||||
}
|
||||
|
||||
/// Remove the isolated worktree (and its new branch) of a spawn refused
|
||||
/// before its child started. No agent ever ran in it, so everything it holds
|
||||
/// came from the checkout itself, including what post-checkout hooks,
|
||||
/// line-ending filters or LFS wrote there. Requiring a pristine
|
||||
/// `git status`, as [`remove_unchanged_worktree`] does for a finished
|
||||
/// worker, kept exactly those checkouts behind. The removal still goes
|
||||
/// through the lane implementation, which deletes only a path git lists as a
|
||||
/// linked worktree, and the branch only when merged.
|
||||
pub(super) fn remove_unstarted_worktree(worktree: &Path) {
|
||||
let outcome = codewhale_lane::remove_worktree_if_expired(worktree, Some(0), None);
|
||||
if outcome.is_err() || worktree.exists() {
|
||||
tracing::warn!(
|
||||
"could not remove the worktree of a refused sub-agent spawn {}: {}",
|
||||
worktree.display(),
|
||||
outcome.err().map_or_else(
|
||||
|| "git did not list it as a linked worktree".to_string(),
|
||||
|err| format!("{err:#}")
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn git_repo_root(workspace: &Path) -> Result<PathBuf, ToolError> {
|
||||
const MAX_PARENT_LEVELS: usize = 4;
|
||||
let start = workspace
|
||||
@@ -315,7 +337,7 @@ fn resolve_worktree_path(
|
||||
}
|
||||
}
|
||||
}
|
||||
None => default_root.join(sanitize_worktree_slug(branch)),
|
||||
None => default_root.join(branch_worktree_slug(branch)),
|
||||
};
|
||||
let normalized = normalize_path_lexically(&path);
|
||||
let repo_canonical = repo_root
|
||||
@@ -425,7 +447,36 @@ fn default_worktree_root(repo_root: &Path) -> PathBuf {
|
||||
normalize_path_lexically(&parent.join(SUBAGENT_WORKTREE_ROOT_DIR).join(repo_name))
|
||||
}
|
||||
|
||||
/// The default checkout directory for `branch`: its slug, or, when the slug
|
||||
/// had to be cut to [`WORKTREE_SLUG_MAX`], the cut slug with a short hash of
|
||||
/// the whole branch name, so two long branches sharing a prefix get their own
|
||||
/// checkouts instead of the second spawn failing on an existing path.
|
||||
fn branch_worktree_slug(branch: &str) -> String {
|
||||
let slug = sanitize_worktree_slug(branch);
|
||||
if slug == sanitize_worktree_slug_within(branch, usize::MAX) {
|
||||
return slug;
|
||||
}
|
||||
use sha2::{Digest, Sha256};
|
||||
let digest = Sha256::digest(branch.as_bytes());
|
||||
let suffix: String = digest[..4]
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
// The slug is ASCII, so cutting by bytes cannot split a character.
|
||||
let mut prefix = slug;
|
||||
prefix.truncate(WORKTREE_SLUG_MAX - suffix.len() - 1);
|
||||
let prefix = prefix.trim_end_matches(['-', '.', '_']);
|
||||
format!("{prefix}-{suffix}")
|
||||
}
|
||||
|
||||
/// Longest sub-agent worktree path slug.
|
||||
const WORKTREE_SLUG_MAX: usize = 48;
|
||||
|
||||
fn sanitize_worktree_slug(input: &str) -> String {
|
||||
sanitize_worktree_slug_within(input, WORKTREE_SLUG_MAX)
|
||||
}
|
||||
|
||||
fn sanitize_worktree_slug_within(input: &str, max: usize) -> String {
|
||||
let mut slug = String::new();
|
||||
for ch in input.chars() {
|
||||
let normalized = if ch.is_ascii_alphanumeric() {
|
||||
@@ -439,7 +490,7 @@ fn sanitize_worktree_slug(input: &str) -> String {
|
||||
continue;
|
||||
}
|
||||
slug.push(normalized);
|
||||
if slug.len() >= 48 {
|
||||
if slug.len() >= max {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -507,7 +558,7 @@ mod tests {
|
||||
first.starts_with("codex/agent-audit-authentication-midd"),
|
||||
"{first}"
|
||||
);
|
||||
let paths = [&first, &second, &sibling].map(|branch| sanitize_worktree_slug(branch));
|
||||
let paths = [&first, &second, &sibling].map(|branch| branch_worktree_slug(branch));
|
||||
for (branch, path) in [&first, &second, &sibling].into_iter().zip(&paths) {
|
||||
assert_eq!(
|
||||
path.as_str(),
|
||||
@@ -522,6 +573,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// An explicit branch with no worktree_path gets its path from the branch
|
||||
/// slug, cut at 48 characters. Two long branches sharing that prefix used
|
||||
/// to map to one checkout, and the second spawn failed on it.
|
||||
#[test]
|
||||
fn long_explicit_branches_sharing_a_prefix_get_distinct_paths() {
|
||||
let a = "feature/very-long-shared-prefix-authentication-work-a";
|
||||
let b = "feature/very-long-shared-prefix-authentication-work-b";
|
||||
let (path_a, path_b) = (branch_worktree_slug(a), branch_worktree_slug(b));
|
||||
assert_ne!(path_a, path_b);
|
||||
for path in [&path_a, &path_b] {
|
||||
assert!(path.len() <= WORKTREE_SLUG_MAX, "{path}");
|
||||
assert!(
|
||||
path.starts_with("feature-very-long-shared-prefix"),
|
||||
"{path}"
|
||||
);
|
||||
}
|
||||
assert_eq!(path_a, branch_worktree_slug(a), "the path is stable");
|
||||
assert_eq!(
|
||||
branch_worktree_slug("feature/short"),
|
||||
"feature-short",
|
||||
"a branch that fits keeps its plain slug"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cwd_errors_name_the_allowed_root() {
|
||||
let workspace = tempfile::tempdir().expect("workspace");
|
||||
|
||||
Reference in new issue
Block a user