Files
PaperClipAI/packages/adapter-utils
8b873e0112 fix(adapter-utils): skip the materialize lock for an already-current skill (#17061)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A remote Claude ACP run stages the selected runtime skills into the
sandbox. The host archives the materialized skills directory with tar
and uploads it.
> - Concurrent runs of one agent share that skills directory. The
directory key is the selected skill set and its content.
> - Each run calls `materializePaperclipSkillCopy` for each skill. The
function always created and removed a sibling `<skill>.lock` directory,
even when the copy was already current.
> - The archive step lists the directory entries first and then gives
the names to tar. When a concurrent run removed its lock between these
two steps, tar failed with `Cannot stat` and the whole run failed as
`adapter_failed`.
> - This pull request checks the materialized fingerprint before it
takes the lock. An up-to-date copy now leaves no transient entries in
the shared directory.
> - The benefit is that concurrent runs of one agent no longer fail each
other's skill staging.

## Linked Issues or Issue Description

**What happened?**
A remote `claude_local` run failed before the agent started. The skill
staging step ran `tar -c ... -T entries.nul` on the shared
runtime-skills directory. Tar stopped with `tar: <skill>.lock: Cannot
stat: No such file or directory` and exit status 2. Another run of the
same agent was materializing the same skills at the same time.

**Expected behavior**
A run that finds its skills already materialized does not change the
shared skills directory. Concurrent runs can archive that directory
safely.

**Steps to reproduce**
1. Materialize a skill into a skills directory.
2. Call `materializePaperclipSkillCopy` again in a loop for the same
skill.
3. At the same time, call `createTarballFromDirectory` on the skills
directory in a loop.
4. Before this change, most archives fail with `Cannot stat` on
`<skill>.lock`.

**Agent adapter(s) involved**
`claude_local` (ACP, remote sandbox). The function is shared, so
`grok_local` and the Codex and Gemini ACP skill paths also benefit.

## What Changed

- `packages/adapter-utils/src/server-utils.ts`:
`materializePaperclipSkillCopy` hashes the source and checks the
materialized sentinel before it takes the lock. When the copy is
current, it returns without creating the lock directory or a temporary
copy. The existing check inside the lock stays, so a copy that is
missing or out of date still uses the lock as before. The source is
hashed once per call, the same as before.
- `packages/adapter-utils/src/server-utils.test.ts`: a new test holds a
live lock from another owner, then materializes an already-current
skill. The call must return at once and must not change the lock or the
directory.

## Verification

- `cd packages/adapter-utils && npx vitest run
src/server-utils.test.ts`: 132 passed.
- Red/green: without the fix, the new test times out because the call
waits on the live lock. With the fix, it passes.
- `npx vitest run src/acpx-engine/execute.test.ts -t skill`: 15 passed.
- `npx tsc --noEmit -p tsconfig.json` in `packages/adapter-utils`:
passes.
- Real-dependency race check (a temporary script, not committed). The
script used real files and the system tar. It ran
`materializePaperclipSkillCopy` for 5 skills in a loop while
`createTarballFromDirectory` archived the same directory for 8 seconds:
- Before the fix: 1994 of 2307 archives failed with `tar: skill-1.lock:
Cannot stat: No such file or directory`.
  - After the fix: 0 of 2021 archives failed.
- Assumption under test: the sentinel check outside the lock is safe.
The sentinel is written into the temporary copy before the atomic
rename, so a matching sentinel means a complete copy. The lock never
protected readers: the caller already reads `SKILL.md` after the lock is
released.
- Remaining limit: the first materialization of a new skill set still
uses the lock. A concurrent archive of the same directory is unlikely
then, because a run archives the directory only after it has
materialized every skill in it.

## Risks

- Low risk. The change only skips the lock when the result is already
correct. A missing, damaged, or out-of-date copy uses the same locked
path as before.
- No change to the materialized layout, the sentinel format, or the lock
format.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with
tool use (shell, file edits, test runs). The exact context-window size
is not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:14:53 -07:00
..

@paperclipai/adapter-utils

Shared utilities for Paperclip adapters: process spawning, environment injection, sandbox/SSH transport, workspace sync, and the round-trip helpers that move code between the local execution-workspace cwd and wherever the agent actually runs.

For the adapter-author guide see docs/adapters/creating-an-adapter.md and the in-repo notes at packages/adapters/AUTHORING.md.

Sandbox bridge deadlines

Command-managed bridge control operations (queue reads, input delivery, setup, and cleanup) have a host-enforced deadline of at most 30 seconds per shell command. A shorter configured timeout still applies. These operations cannot inherit the agent run's hours-long lifetime or wait forever on a provider that ignores its timeout. Long-lived agent session commands keep their own limits.

A control timeout reports a transport failure. It does not prove that the remote command stopped and does not replay an uncertain write. The process session closes failed input delivery and uses its existing shutdown path; normal execution settlement must still verify termination.

No-remote-git contract

The local execution-workspace cwd is the only persistence boundary across runs. No adapter may depend on a git remote for cross-run state.

Adapters that run the agent on a different host should use the SSH round-trip helpers in src/ssh.ts:

  • prepareWorkspaceForSshExecution({ spec, localDir, remoteDir }) — bundles the local cwd (tracked files, dirty edits, untracked additions, and the git history needed to reconstruct it) to remoteDir before the run starts. Runs with no git remote configured.
  • restoreWorkspaceFromSshExecution({ spec, localDir, remoteDir, ... }) — syncs the remote cwd back into localDir after the run, including any new commits the agent created. Also runs with no git remote configured.

prepareRemoteManagedRuntime in src/remote-managed-runtime.ts wraps both calls for adapters that want a per-run remote workspace and an automatic restoreWorkspace() finally hook.

The invariant is pinned by the no-remote-git contract case in src/ssh-fixture.test.ts, which asserts that a remote-only commit propagates to the local worktree through the prepare → restore round-trip with no git remote configured at any point. Do not regress that test.