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:
Hunter BandClaude Opus 5.5 committed 2026-09-29 10:00:17 -07:00
1 parent 24ede12e22
commit b43625aac5
3 files changed
+130 -20

No files matched your search

+17 -14
View File
@@ -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(),
}
}
}
+35 -3
View File
@@ -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()
+78 -3
View File
@@ -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");