skills: let the skillset own the skills it owns, and stop a dangling link from killing boot
Lint / actionlint (push) Successful in 16s
Lint / hadolint (push) Successful in 20s

Baked skill links won over the live skillset clone for all three vendored
skills, so a pushed edit to skills/mempalace/SKILL.md was invisible in every
container until the next image build -- measured on two hosts (live md5
129bcc4752 vs baked 5236024fef). Cause was ordering, not intent: the baked links
are created early with a create-only-when-absent guard to close a smoke
readiness race, and the skillset deploy runs last and treats them as foreign.
The comment claimed the opposite of the behaviour.

The fix is not "skillset always wins". Ownership is per-skill: pi-extensions is
owned by its package repo and copied over the snapshot at build time, so the
skillset's lagging duplicate must keep losing; pi-devbox-environment is authored
here. Only mempalace is skillset-owned. devbox-skill-reconcile therefore runs
after the deploy and repoints only the names in skills/skillset-owned.txt,
replacing a link solely when it points into the baked tree, so a real directory
or a link pointing elsewhere is never disturbed. Precedence is now user override
-> live clone (owned names) -> baked snapshot, with the early links intact as
the fallback so the readiness race stays closed.

Reviewing that turned up a latent boot-abort in the pre-existing baked-link
block: `[ ! -e "$link" ]` is TRUE for a dangling symlink, so once a link can
point into /workspace/skillset, a vanished mount makes plain `ln -s` fail with
"File exists" -- and under `set -euo pipefail` that aborts container start
before `exec "$@"`. Reachable on `docker restart` or a host reboot, not on a
recreate, since ~/.agents is not a volume on any host. Now `ln -sfn`, which
heals the link back to the baked fallback.

Smoke additions cover what let this ship: the stale-snapshot canary grepped a
phrase present in BOTH the stale and fresh copies, so it passed throughout;
it now pins the newest section. Link targets are asserted, not just `test -L`;
the owned-list content is asserted both ways; and the reconciler's replace path
-- which no CI container exercises, since none mounts a skillset -- is covered by
fabricating one. A mutation test showed the obvious three assertions still pass
with the "is this link ours?" guard deleted, so a discriminating case was added:
an owned name whose link is a user override outside the baked tree.

Also refreshes the mempalace snapshot to skillset 670f7f1 (without it the fix
helps only hosts that mount skillset) and corrects README, which documented the
old, wrong precedence in three places.

Verified with 12 fixture cases plus 2 mutants: ownership respected against the
real trees, user overrides preserved, relative/trailing-slash/CRLF/space/glob
inputs handled, dangling link healed, read-only skills dir exits 0, idempotent.
This commit is contained in:
2026-08-23 20:59:14 +02:00
parent 4f6f470518
commit b5810654f6
9 changed files with 365 additions and 43 deletions
+17 -4
View File
@@ -547,7 +547,8 @@ directory, and they compose:
`~/.agents/skills/` by `entrypoint-user.sh` on every start. They need no
external mount, survive volume recreate (the source is an image path, not a
home dir a named volume would shadow), and are created only when absent so a
same-named skillset skill or user override is never clobbered. The bundled
user override is never clobbered. Precedence against a mounted `skillset` repo
is per-skill, not blanket — see *Skillset repo* below. The bundled
**`pi-devbox-environment`** skill is delivered this way — it teaches agents
the container's persistence model, host/LAN SSH reachability, split-DNS
mechanisms, the interactive-vs-tool-shell alias gotcha (`dssh`/`dscp`),
@@ -558,8 +559,11 @@ directory, and they compose:
pi session to read `~/.agents/skills/pi-extensions/SKILL.md` at start (to fix
fork/recall under-utilisation). That pointer would dangle in a container
started *without* the private `skillset` repo, so the image also bakes
fallback copies of **`pi-extensions`** and **`mempalace`**. They are
symlinked only when absent, so a mounted skillset always overrides them. The
fallback copies of **`pi-extensions`** and **`mempalace`**. Whether a mounted
skillset overrides them depends on who *owns* the skill (see *Skillset repo*):
`mempalace` is skillset-owned, so the live clone wins; `pi-extensions` is
owned by its package repo, so the baked copy keeps winning — the skillset's
copy of it is a downstream duplicate that can lag. The
`pi-extensions` skill is *layered*: a committed snapshot in `rootfs/` is the
floor, and `Dockerfile.variant` copies the canonical, package-owned copy from
the pinned `pi-extensions` clone (`/opt/pi-extensions/skill/`) over it at
@@ -573,7 +577,16 @@ directory, and they compose:
- **Skillset repo (optional).** If a `skillset` repo is mounted (at
`$HOME/skillset` or `/workspace/skillset`, or via `SKILLSET_CONTAINER_PATH`),
`deploy-skills.sh` symlinks its skills in too. Image-baked skills are
classified as foreign-links by its `--prune-stale` pass and left untouched.
classified as foreign-links by its `--prune-stale` pass and left untouched —
which through v1.8.4 meant the baked copy *always* won, so an edit pushed to a
skillset-owned skill was invisible until the next image build. Since v1.8.5
`devbox-skill-reconcile` runs right after the deploy and repoints the links for
skills the skillset owns, listed in
`/usr/local/share/pi-devbox/skills/skillset-owned.txt` (today: `mempalace`).
Effective precedence, highest first: **user override** (a real directory, or a
symlink pointing outside the baked tree) → **live skillset clone** (owned names
only) → **baked snapshot** (everything else, and every skill when no skillset
is mounted). Check with `readlink -f ~/.agents/skills/<skill>`.
To make agents *proactively* load a baked skill at session start (rather than
only on description match), the image appends a short, gated pointer to the