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
+28 -5
View File
@@ -58,10 +58,17 @@ fi
# the runtime skill-link assertion. Pointing at the image path (/usr/local/...)
# keeps the skill fresh from the image and surviving volume recreate (unlike
# anything baked under a home dir, which a named volume would shadow). Created
# only when absent, so a same-named skillset skill (deployed later, at the end
# of this script) or a user override is never clobbered; the skillset deploy
# classifies these as foreign-links and its --prune-stale pass leaves them
# alone (only dangling symlinks are pruned).
# only when absent, so a user override is never clobbered.
#
# NB: "created only when absent" does NOT hand a same-named skillset skill
# priority — the opposite. The skillset deploy runs at the end of this script
# and classifies these links as foreign, so through v1.8.4 the BAKED copy
# always won and an edit pushed to a skillset-owned skill was invisible until
# the next image build. The links below are therefore the FALLBACK only;
# devbox-skill-reconcile (invoked right after the skillset deploy) hands the
# skillset-OWNED skills back to the live clone. Ownership is per-skill, listed
# in skills/skillset-owned.txt — see VENDORED.md for why pi-extensions must
# keep losing to the baked copy.
DEVBOX_SKILLS_SRC=/usr/local/share/pi-devbox/skills
if [ -d "$DEVBOX_SKILLS_SRC" ]; then
mkdir -p "$HOME/.agents/skills"
@@ -69,7 +76,16 @@ if [ -d "$DEVBOX_SKILLS_SRC" ]; then
[ -d "$_sk" ] || continue
_skname=$(basename "$_sk")
if [ ! -e "$HOME/.agents/skills/$_skname" ]; then
ln -s "${_sk%/}" "$HOME/.agents/skills/$_skname"
# -sfn, not -s: `[ ! -e ]` is TRUE for a DANGLING symlink (-e follows the
# link), and since v1.8.5 these links can point into /workspace/skillset
# (see devbox-skill-reconcile, invoked after the skillset deploy). If that
# mount vanishes while the writable layer survives — a `docker restart` or
# a host reboot under restart: unless-stopped, as opposed to a recreate —
# plain `ln -s` fails with "File exists" and, under `set -e`, aborts
# container start before `exec "$@"`. With -f the broken link heals back to
# the baked fallback, and the reconciler re-points it in the same boot if
# the clone is back.
ln -sfn "${_sk%/}" "$HOME/.agents/skills/$_skname"
fi
done
fi
@@ -385,6 +401,13 @@ elif [ -x /workspace/skillset/deploy-skills.sh ]; then
fi
if [ -n "$SKILLSET_DEPLOY" ]; then
"$SKILLSET_DEPLOY" --bootstrap --prune-stale >/dev/null 2>&1 || true
# The deploy leaves the early baked links (above) in place as foreign links,
# which silently shadows the live clone for skills the skillset OWNS. Repoint
# just those; baked stays the fallback, user overrides still win. `|| true`:
# a skill-link refinement must never break container start.
if command -v devbox-skill-reconcile >/dev/null 2>&1; then
devbox-skill-reconcile "$(dirname "$SKILLSET_DEPLOY")" || true
fi
fi
# ── Execute command ──────────────────────────────────────────────────