Recover hook install when a running agent CLI holds the plugin directory - #645
Recover hook install when a running agent CLI holds the plugin directory#645Yuandi (DDKinger) wants to merge 4 commits into
Conversation
With an agent CLI already running elsewhere -- most commonly VS Code, which
keeps several `copilot` processes alive for the whole editor session -- every
hook install, update, and uninstall failed:
> copilot plugin install wt-agent-hooks@wt-local
Failed to install plugin: Error: Failed to install plugin: Access is denied. (os error 5)
A live CLI process holds an open handle on the plugin directory it loaded at
startup, so the CLI's "replace the installed plugin folder" step can neither
rename nor remove that directory. The handle only blocks operations on the
directory itself: creating, overwriting, and deleting entries inside it all
still succeed.
`run_plugin_cli_unlocking` uses that asymmetry. When a plugin CLI fails with
the locked-directory signature, it copies the directory contents aside, empties
the directory with file-level deletes, and runs the same command again. On
success the stash is discarded; on a second failure the contents are restored
so the user's existing hooks are left exactly as they were. The directory
itself is never removed, which also keeps the working directory valid for live
CLI sessions that spawn hooks from it.
Wired into install, update, and uninstall for Copilot, Claude, Codex, and
Gemini. OpenCode opts out: its hooks are a wta-owned file copy with no
CLI-owned directory-replace step.
Verified against Copilot CLI 1.0.81 with twelve live `copilot` processes:
installing into the populated directory fails every time, installing into the
same directory after clearing succeeds, and a retry that still fails restores
the previous contents.
Also extracts `plugin_cli_reached_goal` and `plugin_cli_exit_error` from
`run_plugin_cli_with_env` so both paths report failures identically; the error
now carries the CLI's own message instead of just an exit code.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: c7e8cb55-7f80-4b1a-93e8-dd04d46c8053
There was a problem hiding this comment.
Pull request overview
This PR hardens WTA’s agent hook install/update/uninstall flows against a common Windows failure mode where a long-running agent CLI (often VS Code’s copilot processes) holds an open handle on the installed plugin directory, preventing directory-level replace/remove operations. It introduces a targeted “unlock by clearing contents then retry” recovery path and expands unit test coverage around signature matching and rollback behavior.
Changes:
- Route Copilot/Claude/Codex/Gemini hook install/update/uninstall through
run_plugin_cli_unlocking, which detects the locked-directory signature, stashes contents, clears the directory contents (without removing the directory), and retries the CLI command. - Refactor plugin CLI result handling into
plugin_cli_reached_goal+plugin_cli_exit_errorso failures consistently surface the CLI’s own message (not just an exit code). - Add new unit tests covering locked-directory signature detection, per-CLI installed-dir layout, clear/stash/restore behavior, and error plumbing.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| tools/wta/src/agent_hooks_installer.rs | Adds locked plugin-directory recovery logic, wires it into install/update/uninstall flows, and refactors plugin CLI goal/error handling. |
| tools/wta/src/agent_hooks_installer_tests.rs | Adds unit tests for failure-signature matching, directory layout resolution, clear/stash/restore round-trip, and improved error messages. |
Suppressed comments (1)
tools/wta/src/agent_hooks_installer.rs:3121
restore_plugin_dir_contentsusescopy_dir_recursive(stash, dir)without clearingdirfirst. If the retry created any new files/subdirectories before failing, those extra entries will remain and the “restore” won’t actually return the plugin directory to its previous state. To make the rollback reliable, cleardir’s contents before copying back fromstash(and keepstashif the clear/copy fails).
/// Put a stash taken by [`stash_plugin_dir_contents`] back and discard
/// it. Best-effort: a failure is logged at error because it leaves the
/// plugin directory empty, with the only surviving copy in the stash.
fn restore_plugin_dir_contents(stash: &Path, dir: &Path) {
match copy_dir_recursive(stash, dir) {
Ok(()) => discard_plugin_dir_stash(stash),
Err(e) => tracing::error!(
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
This comment has been minimized.
This comment has been minimized.
Two review findings on the locked-plugin-directory recovery. `stash_plugin_dir_contents` propagated a `clear_dir_contents` failure directly, so a clear that died partway left the plugin directory half-emptied -- the opposite of the documented promise that an unrecoverable failure leaves the user's existing hooks exactly as they were. It now refills the directory from the stash before returning the error. `restore_plugin_dir_contents` already copies over whatever is still present, so it handles the half-populated case without change; that is now stated in its doc comment and covered by a test. `check-spelling` rejected the bare `ebusy` needle. Dropping it and its `eperm` sibling loses no coverage: libuv always spells the code out in prose (`EPERM: operation not permitted, ...`), and the prose needles `operation not permitted` / `resource busy` were already in the list, so the same messages still match. The existing libuv-message test proves it. Rewording beats an allowlist entry for a token we only ever match as a substring of a longer phrase. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c7e8cb55-7f80-4b1a-93e8-dd04d46c8053
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (1)
tools/wta/src/agent_hooks_installer.rs:3146
restore_plugin_dir_contentsrestores by callingcopy_dir_recursive(stash, dir)without first removing entries that may have been created by the failed retry. Becausecopy_dir_recursiveonly creates/overwrites paths and never deletes preexisting destination entries, a retry that partially wrote new files can leave extra files behind, so the plugin dir may not be restored to its pre-recovery state (potentially mixing old + new hook contents). Consider clearingdir’s contents (leaving the directory itself) before copying fromstash, and only discarding the stash after both the clear and copy succeed.
/// Put a stash taken by [`stash_plugin_dir_contents`] back and discard
/// it. Copies over whatever is still in `dir`, so it works for both a
/// fully emptied directory and one a failed clear left half-populated.
/// Best-effort: a failure is logged at error because it leaves the
/// plugin directory incomplete, with the only surviving copy in the
/// stash.
fn restore_plugin_dir_contents(stash: &Path, dir: &Path) {
match copy_dir_recursive(stash, dir) {
Ok(()) => discard_plugin_dir_stash(stash),
Err(e) => tracing::error!(
target: "agent_hooks",
err = %e,
stash = %stash.display(),
dir = %dir.display(),
"failed to restore plugin directory contents; the previous contents remain in `stash`",
),
}
}
Addresses a suppressed finding from the latest Copilot review, which is correct: `restore_plugin_dir_contents` used `copy_dir_recursive` alone, and that only creates and overwrites -- it never deletes destination entries the source lacks. That is harmless on the rollback path in `stash_plugin_dir_contents`, where `dir` only ever holds a subset of the stash. It is not harmless on the retry path: the CLI runs against the cleared directory and can write part of the new plugin before failing, so entries with no counterpart in the stash survive the restore and the user ends up with a mix of old and new hook files rather than the pre-recovery state. The directory is now emptied first. The stash is kept unless both the clear and the copy succeeded, since it is the only pristine copy left when either step fails. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c7e8cb55-7f80-4b1a-93e8-dd04d46c8053
|
Picked up the suppressed finding from the last review (
1621 tests pass. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (3)
Previously missed (3) — in code that hasn't changed since the last review.
tools/wta/src/agent_hooks_installer.rs:3274
spawn_step_unlockingformats failures asfail: <exe> <args> :: <error>, butplugin_cli_exit_erroralready embeds<exe> <args>in the error string. This produces redundant, noisy messages likefail: copilot ... :: copilot ... exited 1: ...in uninstall output. Consider changing the failure formatting to avoid duplicating the command (e.g.fail: {e}orfail: exited ...: ...).
Err(e) => {
messages.push(format!("fail: {} {} :: {}", exe, args.join(" "), e));
false
tools/wta/src/agent_hooks_installer.rs:2924
run_plugin_cli_with_envstill takes alog_targetparameter (now named_log_target) but never uses it; all tracing in this path uses the hard-coded target "agent_hooks". Keeping an unused parameter makes call sites misleading and suggests per-CLI log routing that no longer happens. Consider removing the parameter (and updating callers) or reintroducing a meaningful use (e.g. a structured field) so intent is clear.
args: &[&str],
env: &[(&str, &str)],
_log_target: &str,
idempotency_substrings: &[&str],
tools/wta/src/agent_hooks_installer.rs:3085
is_locked_plugin_dir_failurecurrently treats any occurrence of generic phrases like "operation not permitted" / "resource busy" as a locked-plugin-dir signature. But this file already documents another EPERM case (EPERM: operation not permitted, scandir ...when installing from WindowsApps) that is unrelated to directory locks; matching it would unnecessarily clear an existing plugin dir and could temporarily break running hook spawns. Consider tightening the predicate to require a more specific combination (e.g.os error 5/32/ "access is denied" / "being used by another process", or for Node errors require EPERM/EBUSY and an operation likerename/rmdir).
fn is_locked_plugin_dir_failure(stdout: &str, stderr: &str) -> bool {
matches_idempotency_substring(stdout, stderr, LOCKED_PLUGIN_DIR_NEEDLES)
}
Two more suppressed findings from the latest Copilot review. `is_locked_plugin_dir_failure` treated any `operation not permitted` or `resource busy` as a locked plugin directory. `install_for_claude` documents an unrelated failure with exactly that prose -- `EPERM: operation not permitted, scandir '...'`, raised while *reading* a WindowsApps bundle source -- so a bundle-staging regression could have sent a perfectly good install down the destructive recovery path. That contradicts the predicate's stated purpose of being narrow enough that an unrelated failure never clears a working install. The Win32 spellings stay conclusive on their own: they only arise for this condition. The generic libuv prose now has to be paired with an operation that actually mutates the directory (`rename`, `rmdir`, `unlink`), which is what libuv names right after the prose. `scandir` reads, so it no longer qualifies. `spawn_step_unlocking` also printed the command twice, because `plugin_cli_exit_error` already embeds it: `fail: copilot ... :: copilot ... exited 1: ...`. It now prints the error alone. Spawn failures went through unwrapped and were the one error shape that did *not* name the command, so `plugin_cli_spawn_error` gives them the same treatment rather than dropping that context. Declining the third finding: `run_plugin_cli_with_env`'s unused `_log_target` predates this PR and removing it would touch call sites unrelated to the bug being fixed here. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c7e8cb55-7f80-4b1a-93e8-dd04d46c8053
Summary
With an agent CLI already running elsewhere — most commonly VS Code, which keeps several
copilotprocesses alive for the whole editor session — every hook install, update, and uninstall fails:This hits both entry points: the auto-upgrade at
wta-masterstartup and the Settings UI / FRE "Install hooks" button.Root cause
A live CLI process holds an open handle on the plugin directory it loaded at startup, so the CLI's "replace the installed plugin folder" step can neither rename nor remove that directory.
The handle only blocks operations on the directory itself. Probing
~/.copilot/installed-plugins/wt-local/wt-agent-hooks/while 12copilotprocesses were live:Access is deniedFix
run_plugin_cli_unlockinguses that asymmetry. When a plugin CLI fails with the locked-directory signature, it copies the directory contents aside, empties the directory with file-level deletes, and runs the same command again.The directory itself is never removed. That also keeps the working directory valid for live CLI sessions, which spawn hooks with their
cwdset to the plugin root — removing it out from under them makes their hook spawns fail withos error 267.Wired into install / update / uninstall for Copilot, Claude, Codex, and Gemini. OpenCode opts out: its hooks are a wta-owned file copy with no CLI-owned directory-replace step.
Behavior is unchanged unless all of these hold: the CLI ran but did not reach its goal state, its output matches the locked-directory signature, and the plugin directory exists and is non-empty.
Drive-by:
plugin_cli_reached_goalandplugin_cli_exit_errorare extracted out ofrun_plugin_cli_with_envso both paths report failures identically. The error now carries the CLI's own message instead of just an exit code, sowta-install-hooks.logsays why a step failed.Validation
Verified against Copilot CLI 1.0.81 with twelve live
copilotprocesses.Manual, before the fix — establishing that clearing is sufficient:
End-to-end with the built binary, against a held directory handle:
retry succeeded after clearing the locked plugin directory, plugin contents present, stash discarded.plugin=ok marketplace=ok staging=ok.Automated:
11 new tests cover the failure-signature matching (including the
os error 32sharing-violation spelling and the negative cases that must not trigger the destructive path), the per-CLI directory layouts,clear_dir_contentsleaving the directory in place, the stash/restore round-trip, and the error-message plumbing.Notes for reviewers
is_locked_plugin_dir_failuregates it so an unrelated failure — a missing marketplace, a stale source path — never causes us to clear a working install.cargo fmtwas not run crate-wide: the committed tree is not clean under the locally installed rustfmt and formatting everything would rewrite ~46k lines.agent_hooks_installer.rswas verified to be rustfmt-clean on its own.