Skip to content

Recover hook install when a running agent CLI holds the plugin directory - #645

Open
Yuandi (DDKinger) wants to merge 4 commits into
mainfrom
dev/yuazha/fix-locked-plugin-dir-install
Open

Recover hook install when a running agent CLI holds the plugin directory#645
Yuandi (DDKinger) wants to merge 4 commits into
mainfrom
dev/yuazha/fix-locked-plugin-dir-install

Conversation

@DDKinger

Copy link
Copy Markdown
Contributor

Summary

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 fails:

> copilot plugin install wt-agent-hooks@wt-local
Failed to install plugin: Error: Failed to install plugin: Access is denied. (os error 5)

This hits both entry points: the auto-upgrade at wta-master startup 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 12 copilot processes were live:

Operation Result
Rename / remove the directory Access is denied
Create a file or subdirectory inside
Overwrite an existing file
Delete a file or subdirectory inside

Fix

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.

  • Retry succeeds → the stash is discarded.
  • Retry fails again → the contents are restored, and the original CLI error is reported. A permission problem we cannot recover from leaves the user's existing hooks exactly as they were.

The directory itself is never removed. That also keeps the working directory valid for live CLI sessions, which spawn hooks with their cwd set to the plugin root — removing it out from under them makes their hook spawns fail with os 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_goal and plugin_cli_exit_error are extracted out of 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, so wta-install-hooks.log says why a step failed.

Validation

Verified against Copilot CLI 1.0.81 with twelve live copilot processes.

Manual, before the fix — establishing that clearing is sufficient:

  • Install into the populated directory → fails, 4/4 attempts.
  • Install into a brand-new directory → succeeds.
  • Empty the directory, then install into it → succeeds, 2/2 attempts.

End-to-end with the built binary, against a held directory handle:

  • Retry succeedsretry succeeded after clearing the locked plugin directory, plugin contents present, stash discarded.
  • Retry still fails → contents restored byte-for-byte, original error surfaced, no data loss.
  • Uninstall under the same lock → plugin=ok marketplace=ok staging=ok.

Automated:

cargo test --target x86_64-pc-windows-msvc --manifest-path tools/wta/Cargo.toml
test result: ok. 1619 passed; 0 failed

11 new tests cover the failure-signature matching (including the os error 32 sharing-violation spelling and the negative cases that must not trigger the destructive path), the per-CLI directory layouts, clear_dir_contents leaving the directory in place, the stash/restore round-trip, and the error-message plumbing.

Notes for reviewers

  • The recovery is deliberately narrow. is_locked_plugin_dir_failure gates it so an unrelated failure — a missing marketplace, a stale source path — never causes us to clear a working install.
  • cargo fmt was 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.rs was verified to be rustfmt-clean on its own.

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
Copilot AI lite review requested due to automatic review settings August 21, 2026 06:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_error so 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_contents uses copy_dir_recursive(stash, dir) without clearing dir first. 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, clear dir’s contents before copying back from stash (and keep stash if 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.

Comment thread tools/wta/src/agent_hooks_installer.rs
Comment thread tools/wta/src/agent_hooks_installer.rs Fixed
@github-actions

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
Copilot AI review requested due to automatic review settings August 23, 2026 08:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_contents restores by calling copy_dir_recursive(stash, dir) without first removing entries that may have been created by the failed retry. Because copy_dir_recursive only 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 clearing dir’s contents (leaving the directory itself) before copying from stash, 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
Copilot AI review requested due to automatic review settings August 23, 2026 08:52
@DDKinger

Copy link
Copy Markdown
Contributor Author

Picked up the suppressed finding from the last review (restore_plugin_dir_contents, agent_hooks_installer.rs:3146) in c45d1e1 — it was correct and worth acting on even though the bot filed it as low-confidence.

copy_dir_recursive 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 survived the restore and the user got a mix of old and new hook files instead of the pre-recovery state.

restore_plugin_dir_contents now empties the directory before copying the stash back, and keeps the stash unless both the clear and the copy succeeded — it is the only pristine copy left if either step fails. Covered by restore_plugin_dir_contents_removes_what_a_failed_retry_left_behind, which stages a partial retry write and asserts both that shared paths roll back and that the leftover subtree is gone.

1621 tests pass.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_unlocking formats failures as fail: <exe> <args> :: <error>, but plugin_cli_exit_error already embeds <exe> <args> in the error string. This produces redundant, noisy messages like fail: copilot ... :: copilot ... exited 1: ... in uninstall output. Consider changing the failure formatting to avoid duplicating the command (e.g. fail: {e} or fail: exited ...: ...).
        Err(e) => {
            messages.push(format!("fail: {} {} :: {}", exe, args.join(" "), e));
            false

tools/wta/src/agent_hooks_installer.rs:2924

  • run_plugin_cli_with_env still takes a log_target parameter (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_failure currently 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 like rename/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
Copilot AI review requested due to automatic review settings August 23, 2026 09:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants