Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aac4a1c323 | |||
| 34cf1e3810 | |||
| e8ddeaf89f | |||
| 49a6534093 | |||
| e070e0bcbf | |||
| dbb78798fb |
@@ -64,17 +64,59 @@ re-brand of opencode-devbox's `pi-only` variant.
|
||||
(`curl -sf 'https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest' | jq -r .version`).
|
||||
Check release notes at https://github.com/earendil-works/pi/releases for
|
||||
the upstream changelog to include in `CHANGELOG.md`.
|
||||
2. Update `CHANGELOG.md` Unreleased → vX.Y.Z section.
|
||||
3. Verify `docker compose up` works locally with the current `latest` image
|
||||
2. **Refresh the vendored mempalace skill snapshot if the skillset moved:**
|
||||
`scripts/vendor-mempalace-skill.sh --check` (reads a real skillset clone,
|
||||
writes nothing). Three exit codes, not two — a stale-but-truthful record is
|
||||
**not** a release blocker, so don't treat any non-zero exit as "must
|
||||
refresh" without reading which one it was:
|
||||
- **0** — the record is truthful. This includes stale-but-truthful
|
||||
(upstream has moved past the recorded ref, or the local clone has
|
||||
uncommitted changes) — a `NOTICE` is printed, but nothing is lying.
|
||||
**Skipping the refresh in this case is the legitimate, sanctioned
|
||||
outcome** — every enrolled host reads its own live skillset clone, so
|
||||
the baked copy is only a no-mount fallback. What is not legitimate is
|
||||
skipping it *silently*: the drift is visible here, in
|
||||
`pi-devbox-version`, and in the manifest, so decide rather than forget.
|
||||
- **1** — a confirmed problem: the vendored bytes provably do NOT match
|
||||
the file at the recorded ref (a lying record), or the recorded ref
|
||||
doesn't even resolve to that path in this clone. Refresh.
|
||||
- **2** — cannot determine (the recorded ref itself isn't resolvable in
|
||||
this clone — commonly a shallow checkout missing history). Fetch full
|
||||
history and re-check before deciding; don't refresh blind.
|
||||
Refresh with `scripts/vendor-mempalace-skill.sh`, which rewrites the file
|
||||
**and** the ARG together so they cannot drift apart, and refuses (exit 1)
|
||||
rather than silently rewinding provenance if the skillset clone's HEAD is
|
||||
behind the already-recorded ref (detached HEAD, older checkout) — pass
|
||||
`--force` only if that rewind is genuinely intended.
|
||||
Two consequences to accept deliberately on an actual refresh: the snapshot
|
||||
is hashed into `base_tag`, so it costs a base rebuild (~67 min); and if the
|
||||
section the phrase canary names has changed, re-pin it in
|
||||
`scripts/smoke-test.sh`.
|
||||
3. Update `CHANGELOG.md` Unreleased → vX.Y.Z section.
|
||||
4. Verify `docker compose up` works locally with the current `latest` image
|
||||
if you're upgrading users from a previous version. Then run the
|
||||
**post-recreate sanity check** inside the running container to confirm
|
||||
persisted volumes survived and the pi runtime wiring re-deployed (not just
|
||||
that the container booted):
|
||||
`docker compose exec devbox bash scripts/recreate-sanity-check.sh --expected-version X.Y.Z`
|
||||
(or just `pi-devbox-sanity --expected-version X.Y.Z` if `cli_utils/bin` is
|
||||
on PATH). This is the runtime peer of the build-time `smoke-test.sh` gate.
|
||||
4. Push tag: `git tag vX.Y.Z && git push origin vX.Y.Z`.
|
||||
5. Watch CI: smoke job builds amd64 only and asserts size + extensions +
|
||||
`docker compose exec devbox bash scripts/recreate-sanity-check.sh --expected-image-version X.Y.Z`
|
||||
(or just `pi-devbox-sanity --expected-image-version X.Y.Z` if
|
||||
`cli_utils/bin` is on PATH). This is the runtime peer of the build-time
|
||||
`smoke-test.sh` gate.
|
||||
**`X.Y.Z` here is the pi-devbox release tag** you are shipping (e.g.
|
||||
`1.8.9`), which is what the rest of this checklist means by `vX.Y.Z`.
|
||||
`--expected-image-version` is the flag that asserts it. There is also an
|
||||
`--expected-version`, and it means something else — the **pi coding agent**
|
||||
version (e.g. `0.84.3`, the `ARG PI_VERSION` pin). Handing the release tag
|
||||
to that one used to report *"pi version mismatch: expected 1.8.8, got
|
||||
0.84.3"*, i.e. a red on the final gate of the release accusing the wrong
|
||||
component; it now tells you to use `--expected-image-version` instead, and
|
||||
the reverse mix-up is caught too. Both flags are optional — with neither,
|
||||
the live pi version is asserted against the version recorded in the image's
|
||||
own build manifest (which catches a stale `pi` in the `~/.pi/npm-global`
|
||||
volume shadowing the baked one) and the image tag is reported
|
||||
informationally.
|
||||
5. Push tag: `git tag vX.Y.Z && git push origin vX.Y.Z`.
|
||||
6. Watch CI: smoke job builds amd64 only and asserts size + extensions +
|
||||
pi version + new-base-tooling presence. Variant build is multi-arch
|
||||
(amd64 + arm64) only after smoke passes. A tag push fires **only**
|
||||
`docker-publish.yml` — `lint.yml` is scoped to `branches: ['**']`, which
|
||||
@@ -85,9 +127,9 @@ re-brand of opencode-devbox's `pi-only` variant.
|
||||
discovery on `head_sha` **and** the workflow `path` — see *Gitea API access*
|
||||
below — because that guard costs nothing and a future workflow added on `v*`
|
||||
would silently reintroduce the ambiguity.
|
||||
6. Verify the Hub tags appear (latest + vX.Y.Z, the `-studio` pair, plus
|
||||
7. Verify the Hub tags appear (latest + vX.Y.Z, the `-studio` pair, plus
|
||||
base-latest if the base was rebuilt this run).
|
||||
7. **Revoke any short-lived Gitea PAT** used during the release at
|
||||
8. **Revoke any short-lived Gitea PAT** used during the release at
|
||||
`gitea.jordbo.se/user/settings/applications`. N/A if you used the
|
||||
`GITEA_ACCESS_TOKEN` env var instead (see *Gitea API access* below) —
|
||||
its lifecycle is managed host-side, nothing to revoke.
|
||||
|
||||
+471
-1
@@ -11,6 +11,476 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
|
||||
|
||||
---
|
||||
|
||||
## v1.8.9 — 2026-08-26
|
||||
|
||||
The coordination log gets a reader, and the release checklist's last gate stops
|
||||
accusing the wrong component.
|
||||
|
||||
### The mailbox arrives — named here *because nothing in this repo caused it*
|
||||
|
||||
**`mempalace-toolkit` main moves `e70bef2` → `5b8d78f` (exactly one commit, 281
|
||||
insertions / 9 deletions across `extensions/pi/mempalace.ts` and
|
||||
`extensions/pi/README.md`), and that is what actually ships the auto-delivered
|
||||
logstream mailbox.** No pi-devbox commit implements it. `docker-publish.yml`
|
||||
resolves `MEMPALACE_TOOLKIT_REF=main` to a concrete SHA at build time and folds
|
||||
that SHA into `base_tag`, so the mailbox would have landed in the next tagged
|
||||
image **whether or not this section existed** — which is precisely why it exists.
|
||||
That is the same shipped-undocumented shape as `553d865` in v1.8.7, and that one
|
||||
caused a cross-host misattribution: an agent on another machine reasoned about
|
||||
which image contained which behaviour from a CHANGELOG that never mentioned it.
|
||||
The rule this release adopts: **if a floating ref will pull a behaviour change
|
||||
into the image, name it in the CHANGELOG before tagging, not after.**
|
||||
|
||||
What the mailbox does, from the shipped code rather than from the design
|
||||
discussion:
|
||||
|
||||
- **The bridge was write-only.** It stamped provenance on the way *out* and never
|
||||
read the log back, so a directed ask reached an agent only if that agent
|
||||
happened to run `mempalace_event_list` itself. The channel carried real
|
||||
cross-machine traffic from 2026-08-18 onward with **zero readers** — every
|
||||
delivery in that window happened because a human said "check your mailbox".
|
||||
- **Doubly gated, exactly like the provenance stamper:** inert unless *both*
|
||||
`MEMPALACE_PI_DEVICE` and `MEMPALACE_REMOTE_URL` are set. An unstamped client
|
||||
has no address to be reached at, so there is nothing for it to read.
|
||||
- **On by default, opt out with `MEMPALACE_MAILBOX=0`.** Deliberate: an opt-in
|
||||
fix for a nobody-remembers-to-do-it problem only relocates the forgetting.
|
||||
Tunables: `MEMPALACE_MAILBOX_POLL_MS` (min gap between mid-session polls,
|
||||
default 300000) and `MEMPALACE_MAILBOX_RESURFACE_MS` (re-announce a still-owed
|
||||
ask after, default 3600000).
|
||||
- **Owed-ness is derived, never read off `status`.** `event_ack` appends and never
|
||||
mutates, and `status` is written once, so a directed `open` keeps matching the
|
||||
mailbox query forever — answered or not. A candidate counts as answered only
|
||||
when one of this device's own events has a **higher `seq`**, joins via
|
||||
`metadata.ack_of` or a shared `correlation_id`, and carries a terminal status
|
||||
(`applied`, `superseded`, `failed`, `blocked`). `claimed` and `ready` are
|
||||
deliberately **not** terminal — that is how "taken, but not finished" keeps
|
||||
resurfacing.
|
||||
- **`*` broadcasts are excluded from the owed set.** `to_agent: <me>` also matches
|
||||
broadcasts per the tool contract, so without this a broadcast written with
|
||||
`status="open"` would make every machine believe it personally owed the same
|
||||
answer — and the code would contradict the skill that documents it.
|
||||
- **The dedup map is in memory on purpose.** A restart forgets, so an already-seen
|
||||
ask can resurface: visible noise a human corrects in one turn. The opposite
|
||||
failure — suppressing an unanswered ask — is silent and permanent. Do not
|
||||
"fix" the noise by persisting it.
|
||||
- **Delivery queues, it never interrupts.** A sections push at
|
||||
`before_agent_start` plus a second `agent_settled` handler behind the 300 s
|
||||
floor, using `steer` and *not* `triggerTurn`: `agent_settled` means idle, so
|
||||
nothing wakes a model on inbound fleet traffic.
|
||||
|
||||
Measured on v1.8.8 (which bakes `e70bef2`, i.e. no mailbox) immediately before
|
||||
this release: the wake-up mailbox query had to be run by hand, returned **3**
|
||||
directed asks with `status="open"`, and the derivation above resolved **all
|
||||
three** as already answered — the third independent confirmation that the raw
|
||||
`status` filter never shrinks, and the first taken on a fresh container with no
|
||||
memory of having answered them.
|
||||
|
||||
### `--expected-image-version`: two versions, two flags
|
||||
|
||||
**`scripts/recreate-sanity-check.sh --expected-version 1.8.8` reported
|
||||
`✗ pi version mismatch: expected 1.8.8, got 0.84.3` and exit 1** — a red on the
|
||||
final runtime gate of a release, accusing the image of being the wrong version,
|
||||
when the flag had only ever asserted `pi --version`. `AGENTS.md` step 4 spelled
|
||||
it `--expected-version X.Y.Z` inside a checklist where every *other* `X.Y.Z` is
|
||||
the pi-devbox tag; `README.md` got it right, so the two documents disagreed.
|
||||
|
||||
Not hypothetical, and not one reader's slip: the v1.8.8 release-readiness handoff
|
||||
from `pi@emb-7kj4vr4g` (`evt_20260826T134919_a614ecfc2d4f`) propagated
|
||||
`--expected-version 1.8.8` twice, in its body and in
|
||||
`metadata.cannot_check_here`, while correctly calling step 4 "the runtime peer of
|
||||
the smoke gate, so it is not ceremonial". Two independent readers, one on another
|
||||
machine, converged on the wrong meaning. Left alone it puts a spurious red on
|
||||
every release, and the intuitive remedy — re-pull, re-recreate — is pure waste.
|
||||
|
||||
- **New `--expected-image-version X.Y.Z`** asserts the pi-devbox release tag,
|
||||
read from `release_tag` in `/etc/pi-devbox/build-manifest.json` (the image's
|
||||
own build-time ground truth — no checkout, no network, no Docker socket). A
|
||||
leading `v` is optional on either side, so `1.8.9` and `v1.8.9` both work.
|
||||
- **Both flags now detect being handed the other one's value**, and the test is
|
||||
exact rather than heuristic: the value is compared against the *other*
|
||||
quantity this image actually reports, so it can only fire when the mix-up is
|
||||
real. `--expected-version 1.8.9` now says *"is the pi-devbox IMAGE version,
|
||||
not the pi version — use `--expected-image-version`"*, and the reverse mix-up
|
||||
is caught the same way.
|
||||
- **Neither flag is required any more.** With none, the live `pi --version` is
|
||||
asserted against `pi_version` in the build manifest. That is not a tautology:
|
||||
`pi` resolves through `PATH`, and a stale install in the `~/.pi/npm-global`
|
||||
volume can shadow the baked one — the same shadowing this script already
|
||||
guards against for `npm:pi-atelier` in `packages[]`. Verified by mutating the
|
||||
manifest to a different version, which made the new check fail as intended.
|
||||
- **The header note it replaced was stale and load-bearing.** It claimed pi "is
|
||||
resolved from `latest` at CI build time and is NOT pinned … cannot self-derive
|
||||
an expected version". `Dockerfile.variant` pins `ARG PI_VERSION=0.84.3`, and
|
||||
`docker-publish.yml` *reads that ARG* as its source of truth (refusing to
|
||||
build on a floating value, checking it is published on npm, warning when npm
|
||||
is ahead). The same withdrawn claim also sat in `cli_utils`'s
|
||||
`pi-devbox-sanity --help`, the third place this confusion lived; fixed there
|
||||
too, in that repo.
|
||||
- Argument parsing hardened while in there: a flag whose value is missing — or
|
||||
is another flag — is now a usage error (exit 2) instead of silently consuming
|
||||
the next argument, and `--help` works.
|
||||
|
||||
All fourteen flag combinations were exercised by execution, including the two
|
||||
manifest-absent branches and the shadowing branch, which a healthy container
|
||||
cannot reach naturally — mutation-tested with a doctored manifest path so that
|
||||
each failure branch was observed *firing* rather than assumed present.
|
||||
|
||||
### Component audit: no bumps, and that is the finding
|
||||
|
||||
Checked before tagging, since a base rebuild was already forced:
|
||||
|
||||
| Component | In v1.8.8 | Upstream now | Action |
|
||||
|---|---|---|---|
|
||||
| pi (npm) | `0.84.3` (pinned) | `0.84.3` is `latest` | none |
|
||||
| mempalace (PyPI) | `3.8.0` (pinned) | `3.8.0` | none |
|
||||
| pi-atelier | `v0.8.2` (pinned) | `v0.8.2` highest tag | none |
|
||||
| pi-toolkit, pi-extensions, pi-fork, pi-observational-memory, pi-studio | floating | **identical to baked** | none |
|
||||
| skillset snapshot | `6eb20af` | `6eb20af` | none |
|
||||
| **mempalace-toolkit** | `e70bef2` | **`5b8d78f`** | ships the mailbox |
|
||||
|
||||
So the whole ~67-minute base rebuild this tag pays for is attributable to the
|
||||
toolkit SHA alone — `base_tag` folds it, and it moved. Every other floating ref
|
||||
resolved to the commit already baked (verified with `git ls-remote` per repo, not
|
||||
by reading a cached clone).
|
||||
|
||||
One claim in this audit came from a fork that had fabricated its findings — six
|
||||
plausible-looking toolkit commits with five nonexistent SHAs, a pi `0.84.4` that
|
||||
npm has never published, a pi-studio commit `ls-remote` says does not exist, and
|
||||
a compatibility floor of `0.8.2` where the code says `0.7.1`. Every row above was
|
||||
therefore re-measured directly. Recorded because the failure mode is specific:
|
||||
none of it looked wrong, and `git cat-file -e` is what caught it.
|
||||
|
||||
---
|
||||
|
||||
## v1.8.8 — 2026-08-26
|
||||
|
||||
The vendored `mempalace` skill snapshot stops being anonymous, and the
|
||||
container starts saying which copy of each skill it is actually reading.
|
||||
|
||||
**Peer review (pi@emb-7kj4vr4g, logstream correlation
|
||||
`skills-provenance-review`, full text in
|
||||
`drawer_pi-devbox_reviews_e43e766641c9ec85217bc6ce`) found three blockers before
|
||||
this was tagged. All three were the same species: a record asserting something
|
||||
it had not verified. Every finding below was reproduced by execution here before
|
||||
being fixed.**
|
||||
|
||||
- **The verification gate could print `OK` and exit 0 without verifying
|
||||
anything.** `git show <ref>:<path> | sha256sum` hashes *empty stdin* when the
|
||||
ref does not resolve, yielding a real-looking `sha256("")` rather than an
|
||||
empty string — so the `UNKNOWN` branch in `--check` was dead code. Reproduced:
|
||||
a bogus ref reported `MISMATCH` (accusing the snapshot of lying when the true
|
||||
cause was an incomplete clone — and the operator's natural remedy for
|
||||
MISMATCH is to re-run the refresh, which *rewrites provenance to silence the
|
||||
complaint*); with a 0-byte snapshot against a 0-byte upstream file it printed
|
||||
`OK: … exactly skillset@aaaaaaa` and exited 0 for a ref that does not exist.
|
||||
The script already had the right idiom (`sha_empty`) and had applied it to
|
||||
`blob_sha` but not to `at_ref`. Now existence is *proven* with `git cat-file
|
||||
-e` before anything is hashed, at two levels (does the ref resolve; does the
|
||||
path exist at it) because those are different failures. This was the same
|
||||
defect class as the canary it replaces: a check that can succeed without
|
||||
checking. A second, unflagged instance of the identical pipeline shape was
|
||||
found in `blob_sha` and fixed too.
|
||||
- **`--check`'s exit codes conflated "stale" with "lying",** so the release step
|
||||
failed in the case `AGENTS.md` step 2 explicitly calls legitimate. Now: `0`
|
||||
truthful (including stale-but-truthful, with a `NOTICE`), `1` a lying record
|
||||
only, `2` cannot determine (ref absent from this clone). `AGENTS.md` step 2
|
||||
rewritten to state all three, since its promise that "the message
|
||||
distinguishes the two" was exactly what the branch was breaking.
|
||||
- **The staleness `NOTICE` then asserted a direction it had never tested** — the
|
||||
same defect one layer down, found by pi@emb-7kj4vr4g against the real state of
|
||||
its own host. The branch fired the notice on "recorded ≠ HEAD" and announced
|
||||
that HEAD was the newer side, so a clone that was merely *behind* was told
|
||||
*"has moved to 82a8d3c; the snapshot describes the older 5fd0d5c"* when
|
||||
`82a8d3c` is `5fd0d5c`'s **ancestor**. Harmless to the verdict (`rc` stayed 0,
|
||||
nothing was mis-verified) but it points the operator at a refresh — a
|
||||
~67-minute base rebuild — when the real remedy is `git pull`. It now tests
|
||||
ancestry with the `merge-base --is-ancestor` primitive the refresh path two
|
||||
sections above already used, and reports three distinct verdicts: **stale**
|
||||
(recorded is an ancestor — refresh), **your clone is behind** (HEAD is an
|
||||
ancestor — pull, do not refresh), **diverged** (neither). All three verified by
|
||||
execution; only the first was right before.
|
||||
- **`--help` died with `unknown option: --help`.** The strict argument loop that
|
||||
closed the silent-ignore hole never added a `--help` case, so the one script
|
||||
whose argument *order* was itself a landmine had an erroring discoverability
|
||||
path. It now prints its own header block.
|
||||
- **`VENDORED.md` contradicted itself, in the release whose stated invariant is
|
||||
non-contradiction.** Its hand-maintained "Snapshot provenance at last refresh"
|
||||
line named skillset `670f7f1` — seven commits behind the ARG, and *the very
|
||||
commit that told agents to hand-stamp `added_by`*, i.e. the withdrawn
|
||||
instruction this line of work exists to stop shipping — while its `cp` recipe
|
||||
still contradicted the "not `cp`" rule 20 lines above. The hand-maintained
|
||||
line is gone (nothing forced it to move when the ARGs did); `670f7f1` is kept
|
||||
only as a labelled cautionary example. The `pi-extensions` half was verified
|
||||
redundant (CI resolves `PI_EXTENSIONS_REF` via `require_sha`) before removal,
|
||||
rather than silently dropped.
|
||||
|
||||
**Should-fixes from the same review, all reproduced:** `--check` given the
|
||||
documented positional spelling (`<root> --check`) silently ran a *refresh*,
|
||||
because only `$1` was parsed — both tools now parse all arguments and reject
|
||||
unknown ones; a refresh at a detached or older `HEAD` silently rewound ref and
|
||||
bytes, now refused unless the recorded ref is an ancestor (`--force` to
|
||||
override); `upstream_dirty` was computed and never used in check mode, now
|
||||
reported; `pi-devbox-version --no-skills --json` printed human text and broke
|
||||
`jq`; `--help` was a hardcoded `sed -n '2,22p'` range that this branch had
|
||||
already made stale; the skill fingerprint hashed `SKILL.md` alone, so a live
|
||||
skill dir differing only in a sibling file still reported "identical" — and
|
||||
`pi-extensions` already ships two files — so it is now a per-skill **tree** hash
|
||||
and the manifest field is renamed `skillset_snapshot_tree_sha256` to say what it
|
||||
measures; and the `--no-skills` smoke assertion was negative-only, passing on a
|
||||
crashed binary, now anchored positively. `mktemp`+`mv` left written files at
|
||||
`0600` (a `mv` takes the temp file's mode) — CI was unaffected because the git
|
||||
index records `100644`, but a local build from a dirty tree would have baked it;
|
||||
now `chmod 0644` before the `mv`.
|
||||
|
||||
**The skill fix ships outside this release, because it had to.** The review also
|
||||
found that skillset `82a8d3c` — the coordination protocol itself — told every
|
||||
machine on this fleet to *skip* the mailbox it introduced: it gated the mailbox
|
||||
on `mempalace_mesh_peers`, and a hub-and-spoke palace reports `peers: []`
|
||||
precisely because every machine is a thin client of one replica. It also
|
||||
asserted that a directed `open` event "stays in their mailbox until" acked —
|
||||
false, because `event_ack` appends and `status` is written once, so an answered
|
||||
ask matches forever. The headline measurement behind that claim ("exactly 1 —
|
||||
the one that needed a reply") was of an event already acked half an hour
|
||||
earlier. Fixed in skillset `5fd0d5c`, which derives owed-ness by joining on
|
||||
`ack_of`/`correlation_id` with a **`seq` ordering test** — without which one
|
||||
terminal reply suppresses every later ask on the same thread forever. Because
|
||||
the skillset is mounted live on every enrolled host, that correction was already
|
||||
deployed fleet-wide before this image was built; the vendored snapshot is
|
||||
resynced to it (`c04cd15` → `5fd0d5c` → `6eb20af`) so the no-clone fallback does
|
||||
not ship the withdrawn rule. Canary re-verified bidirectionally against the new
|
||||
bytes.
|
||||
|
||||
`6eb20af` adds the limit of that ordering test, found when pi@emb-7kj4vr4g
|
||||
verified it rather than adopting it: **`seq` is replica-local.** It equals
|
||||
`origin_seq` today only because one replica authors events for all four machines,
|
||||
so a second replica could order the same pair differently and derive a different
|
||||
owed-set from the same log — use `hlc` (already on every event, total and
|
||||
causally consistent) once `mesh_peers` reports any peer. Documented as reasoning,
|
||||
not measurement, since a second replica cannot be stood up to test it. The part
|
||||
worth keeping is the **asymmetry**: local-`seq` skew makes an answered item
|
||||
*resurface* (noise, self-correcting, visible), while a timestamp comparison
|
||||
*suppresses an unanswered ask forever* (silent, permanent) — so anyone tempted to
|
||||
"fix" a resurfacing item with `created_at` would be trading the safe failure for
|
||||
the dangerous one.
|
||||
|
||||
**Also carried, previously undocumented:** `dbb7879` resynced the vendored
|
||||
`mempalace` snapshot to skillset `c04cd15` ("the withdrawal only holds where the
|
||||
bridge is live"), landed after the v1.8.7 tag and so absent from that image.
|
||||
⚠️ **A base rebuild is forced** (~67 min): both that resync and the
|
||||
`pi-devbox-version` / `entrypoint-user.sh` changes below touch inputs to
|
||||
`base_tag` (`rootfs/` and `entrypoint*.sh`). The provenance recording itself
|
||||
adds nothing to that cost — it lives entirely in `Dockerfile.variant`.
|
||||
|
||||
Both come from one finding, made while verifying v1.8.7 from inside a freshly
|
||||
recreated container: **the baked `mempalace` snapshot is read by no host on this
|
||||
fleet.** `~/.agents/skills/mempalace` is a symlink to `/workspace/skillset/skills/mempalace`
|
||||
— `entrypoint-user.sh` links the baked skill only `if [ ! -e ]`, and
|
||||
`devbox-skill-reconcile` then repoints the skillset-owned ones at the live clone
|
||||
(that is the v1.8.5 fix working as designed). All four compose stacks in
|
||||
`docker-compose-repo` mount a workspace containing the skillset, so the vendored
|
||||
copy is a CI/no-mount **fallback** and nothing else. Which means the
|
||||
`mempalace skill snapshot is current` canary — the assertion that blocked
|
||||
v1.8.7's first tag — polices a file that no agent on this fleet ever opens,
|
||||
while the drift that *could* actually mislead an agent (a `git pull` nobody ran
|
||||
in `/workspace/skillset`) was invisible from inside the container and is
|
||||
invisible to CI by construction.
|
||||
|
||||
**The rejected fix is worth recording, because it was the obvious one.** The
|
||||
old comment in `scripts/smoke-test.sh` said the real answer was "a CI job
|
||||
diffing this file against the skillset repo". It isn't:
|
||||
|
||||
| Objection | Detail |
|
||||
|---|---|
|
||||
| needs a credential CI does not have | the skillset is **private** (`ssh://git@gitea.jordbo.se:2222/joakimp/skillset.git`); every build-time clone in this image uses anonymous HTTPS, and `resolve-versions`' `gitea_sha()` is explicitly documented as public-repo-only — its 401/403 path exists to survive a *stale token against a public repo*, so a private 403 would return empty and `require_sha` would hard-abort the release |
|
||||
| makes another repo's branch able to fail this build | the same pi-devbox commit would go green today and red tomorrow, and a release could be blocked by an edit in an unrelated repo — precisely the shape of the run 589 failure, but automated and permanent |
|
||||
| pure churn, and it is measurable | pi@emb-7kj4vr4g pushed **four** skillset commits in one evening (`d9dbbbd`, `b740d51`, `3324bd0`, `c04cd15`); a byte-parity gate would have demanded a pi-devbox resync commit **and a ~67-minute base rebuild for each one**, to keep current a copy almost nobody resolves |
|
||||
| guards the wrong artefact | see above: on this fleet, nobody reads it |
|
||||
|
||||
**The invariant is not currency, it is non-contradiction** — the framing comes
|
||||
from pi@emb-7kj4vr4g's review (logstream `project/pi-devbox`, correlation
|
||||
`skillset-vendor-drift`, which also **retracted** its own earlier build-time
|
||||
byte-compare recommendation). A stale-but-self-consistent fallback is harmless;
|
||||
a stale fallback carrying a **withdrawn instruction** is a live footgun, and
|
||||
this project has already paid for that one — through v1.8.4 the baked snapshot
|
||||
*shadowed* the live clone, which is how superseded attribution guidance kept
|
||||
reaching agents. That is precisely what the bidirectional canary asserts, and
|
||||
why it stays.
|
||||
|
||||
So provenance is **recorded** rather than policed, and the check moves to where
|
||||
the skillset actually is — a maintainer's clone, or any running container.
|
||||
|
||||
### Added
|
||||
|
||||
- **`build-manifest.json` now records the vendored snapshot's provenance:
|
||||
`skillset_snapshot_ref` (which skillset commit the bytes are claimed to come
|
||||
from) and `skillset_snapshot_sha256` (the bytes that actually shipped).** The
|
||||
ref is a plain `ARG` **default in `Dockerfile.variant`**, deliberately not a
|
||||
CI-resolved output, which buys three things at once: it needs no credential
|
||||
for a private repo; it keeps a local `docker build` and CI identical by
|
||||
construction (the same reasoning that put `MEMPALACE_VERSION` in
|
||||
`Dockerfile.base` rather than duplicating it in the workflow); and it requires
|
||||
**no change at any of the four `Dockerfile.variant` call sites** (`smoke`,
|
||||
`smoke-studio`, `build-variant`, `build-variant-studio`), whose `--build-arg`
|
||||
lists are hand-duplicated and therefore easy to under-apply to only two.
|
||||
Also emitted as OCI label `se.jordbo.pi-devbox.skillset-snapshot-ref`, so it
|
||||
is readable off the registry without pulling the image.
|
||||
|
||||
Two design points, each arrived at from the file's own rules:
|
||||
|
||||
- **The ref is a claim; the hash is measured.** `Dockerfile.variant` writes
|
||||
the manifest from ground truth (`rev()` on each `/opt` clone, the live
|
||||
`pi --version`), so the snapshot hash is computed with `sha256sum` in that
|
||||
same layer rather than passed in. A build where the two disagree is exactly
|
||||
what the new smoke assertions catch.
|
||||
- **They are siblings, not members of `components{}`.** That map means "HEAD
|
||||
of a clone present in this image" and the skillset is not cloned here —
|
||||
calling it a component would be a lie a future reader would act on. It is
|
||||
also load-bearing mechanically: `pi-devbox-version` renders every
|
||||
`components{}` value with `.value[0:12]`, which would truncate a 64-hex
|
||||
digest into something that looks like a short commit. Same reasoning as
|
||||
`mempalace_version`'s existing comment.
|
||||
|
||||
⚠️ **Costs no base rebuild.** `base_tag` hashes `Dockerfile.base` + `rootfs/`
|
||||
+ `entrypoint*.sh` + the mempalace-toolkit SHA; `Dockerfile.variant` is in
|
||||
none of it. `scripts/check-base-hash.sh` scans `Dockerfile.base` **only**
|
||||
(`DF="Dockerfile.base"`, single hardcoded path), so a new `*_REF` ARG in the
|
||||
variant is invisible to that guard — correctly, since it changes nothing
|
||||
about the base's contents.
|
||||
|
||||
- **`pi-devbox-version` gained a `skills:` section** reporting, per vendored
|
||||
skill, whether the live copy is `baked` or a `live <repo> @ <sha>` clone —
|
||||
and for `mempalace`, whether that live copy matches the baked fingerprint:
|
||||
`(identical to baked snapshot)`, `(baked snapshot <ref> + uncommitted edits)`
|
||||
when the clone is at the recorded commit but the bytes differ, or
|
||||
`(baked snapshot <ref> — live copy differs)`. Same live-vs-baked shape as the
|
||||
existing `pi:`/`palace:` drift annotations. **This is the check CI cannot do
|
||||
and a container can, for free**, since every host that matters already has the
|
||||
skillset mounted. The list iterates the baked tree rather than a hardcoded
|
||||
name list, so vendoring a fourth skill needs no edit here.
|
||||
|
||||
`entrypoint-user.sh` calls it with the new **`--no-skills`** flag: the banner
|
||||
is printed FIRST, before the baked links exist and long before the skillset
|
||||
deploy and reconcile run last, so anything it said about skill sources would
|
||||
describe a state that is about to change. Wrong-but-plausible is worse than
|
||||
absent. (This is the one part of the change that touches `rootfs/` and
|
||||
`entrypoint-user.sh`, so it does cost a base rebuild — already sunk, since
|
||||
`dbb7879` refreshed the vendored snapshot.)
|
||||
|
||||
- **`scripts/vendor-mempalace-skill.sh`** — refreshes the snapshot and rewrites
|
||||
the recorded ref *together*, because a `cp` without a matching ARG bump
|
||||
produces a manifest that confidently lies, which is worse than the anonymous
|
||||
snapshot it replaced. Refuses to record a ref when the upstream file has
|
||||
uncommitted modifications (no commit describes those bytes, so recording one
|
||||
would be a fabrication) — checked on that one file, not the whole tree, so
|
||||
unrelated work in progress in the skillset does not block a vendoring.
|
||||
`--check` answers "is the committed snapshot really `skillset@<recorded
|
||||
ref>`?" and separately reports staleness against the clone's HEAD.
|
||||
|
||||
Counterfactual-tested rather than reasoned about, against throwaway clones:
|
||||
a tampered snapshot reports `MISMATCH` **and** `STALE` (rc 1); a ref rolled
|
||||
back to the previous skillset commit reports `MISMATCH` with content
|
||||
unchanged (rc 1) and a subsequent refresh fixes only the ref, leaving the
|
||||
bytes alone; unstaged and staged-but-uncommitted upstream edits are refused
|
||||
with distinct messages and the snapshot left byte-identical, i.e. the refusal
|
||||
is atomic.
|
||||
|
||||
**Hardened after review** by pi@emb-7kj4vr4g, whose warning was that a resync
|
||||
script must "write the ref it ACTUALLY copied from, or the provenance field
|
||||
inherits the same class of bug the canary just had". The first draft copied
|
||||
the working tree and guarded it with `git diff` — which says nothing about an
|
||||
**untracked** file, and can be clean on a detached or behind checkout while
|
||||
`HEAD` names something else. The snapshot is now *constructed* from
|
||||
`git show HEAD:<path>`, so the recorded pair cannot be a lie by construction,
|
||||
and the untracked case is refused explicitly (tested: it was the one input the
|
||||
first draft would have silently recorded a false ref for). Both new scripts are
|
||||
`bash -n` clean and `shellcheck -S error` clean — the gate v1.8.7 added.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Three stale in-repo markers, all the same failure class.** Two "Unreleased"
|
||||
pointers — `scripts/smoke-test.sh` pointed the reader at "the Unreleased
|
||||
changelog note", and the v1.8.6 correction at "the Unreleased entry above";
|
||||
that section became the `## v1.8.7` heading at release time and neither
|
||||
back-reference was updated. The third: `scripts/smoke-test.sh`'s own coverage
|
||||
list still advertised "typst PDF engine for pandoc **(Unreleased)**", five
|
||||
releases after typst shipped in v1.4.0. Same class as the canary they sit
|
||||
next to: true when written, silently false at release, with nothing checking
|
||||
them. The smoke comment now describes the mechanism that actually shipped
|
||||
(and why the CI-diff idea it advertised was rejected); the changelog one names
|
||||
v1.8.7; the typst line names v1.4.0.
|
||||
|
||||
### Not fixed, deliberately
|
||||
|
||||
- **CI still cannot tell you the vendored snapshot is behind `skillset` main.**
|
||||
That needs a read-only deploy key for a private repo threaded into
|
||||
`resolve-versions`, to warn about a file no host on this fleet reads. Revisit
|
||||
when a no-skillset container becomes a real deployment (shipping the image
|
||||
outside the fleet, or a CI-only agent) — at which point the honest gate is a
|
||||
**warning**, matching the existing `PI_VERSION`/`MEMPALACE_VERSION` policy
|
||||
(concreteness → error, newer-release-exists → warning), never a build
|
||||
failure.
|
||||
- **The phrase canary stays.** It is orthogonal and free: it pins *content*
|
||||
where the new fields pin *provenance*, so it still catches a re-vendored
|
||||
snapshot whose ref was bumped correctly but whose bytes came from the wrong
|
||||
place — and, per the review above, asserting the **absence of withdrawn
|
||||
guidance** is the half of it that earns its keep. Its comment now states the
|
||||
limit instead of promising a fix.
|
||||
- **v1.8.7's published image has no recorded ref**, and that is expected: the
|
||||
field arrives here. Worth knowing when reading one, since the tag move
|
||||
`ebd0de0` → `f645e66` means the published v1.8.7 carries a pre-`dbb7879`
|
||||
snapshot, i.e. its baked mempalace skill lacks c04cd15's "confirm the bridge
|
||||
actually stamps" caveat. Harmless — on v1.8.7 the bridge *is* live, so that
|
||||
caveat self-retires, and every enrolled host reads the live clone anyway.
|
||||
`pi-devbox-version` degrades quietly on such an image: no fingerprint, no
|
||||
annotation, verified against the real v1.8.7 manifest.
|
||||
|
||||
### Documented
|
||||
|
||||
- **The fleet's cross-machine coordination, which was working and unwritten.**
|
||||
The RFC 003 logstream has carried real work between hosts since 2026-08-18 —
|
||||
patch handoff, design review, a v1→v2 supersede — and no document in this repo
|
||||
or the toolkit said so. Written up in three places, split by what each is
|
||||
authoritative for:
|
||||
- `README.md` § *Cross-machine agent coordination* — what the **container**
|
||||
needs: `MEMPALACE_REMOTE_URL` selects the shared palace, and
|
||||
`MEMPALACE_PI_DEVICE` is what makes this machine *reachable* on the log,
|
||||
because when every host is a thin client of one palace the stamped agent name
|
||||
is the only thing distinguishing them. Set both or neither: a container
|
||||
without the device var can read the log but is addressable by nobody.
|
||||
- the skillset's `mempalace` skill (`6eb20af`, live on every host that mounts
|
||||
the skillset, no rebuild needed) — the **norms**: a mailbox query at wake-up,
|
||||
and the sender-declared ack contract, where a *directed* event with
|
||||
`status="open"` is owed a reply and a `*` broadcast owes nothing. The
|
||||
`status` filter earns its place by dropping broadcast noise — measured, an
|
||||
unfiltered mailbox returned 5 events, 4 of them finished broadcasts from
|
||||
eight days earlier — but that is **all** it does; it does not compute
|
||||
owed-ness, and the version of this entry that claimed otherwise is withdrawn
|
||||
above. Measured today, both machines: the raw filter returns 2 asks here and
|
||||
1 there, **every one already answered**, while the derivation returns 0 for
|
||||
both. Dropping noise and deciding what is owed are two different jobs.
|
||||
- mempalace-toolkit `extensions/pi/README.md` (`e70bef2`) — the **mechanism**,
|
||||
including that the bridge is *write-only* today (it stamps events going out
|
||||
and never reads the log, so nothing in this image polls on the agent's
|
||||
behalf), and that live SSE push is a palace-deployment question: the server
|
||||
implements `GET /logstream/stream`, but a reverse proxy exposing only `/mcp`
|
||||
makes it unreachable — verified by 404s against the real endpoint.
|
||||
|
||||
⚠️ **The snapshot was refreshed rather than left stale.** The skill edits landed
|
||||
in the skillset (`5fd0d5c`, then `6eb20af`), so `SKILLSET_SNAPSHOT_REF` was
|
||||
resynced to match and `scripts/vendor-mempalace-skill.sh --check` is a clean
|
||||
`OK` with no notice: the no-clone fallback carries the **corrected** protocol,
|
||||
not the withdrawn one. That mattered more than currency usually does, because
|
||||
the superseded copy contained an instruction — the `mesh_peers` gate — that
|
||||
actively told a reader to skip the feature. Refreshing remains a deliberate
|
||||
release-day decision rather than an automatic one: it costs a base rebuild, and
|
||||
skipping it is legitimate because every enrolled host reads its live clone.
|
||||
What is not legitimate is skipping it *silently*, which is what the new manifest
|
||||
fields and `pi-devbox-version` output make impossible — hence step 2 in
|
||||
`AGENTS.md` § *Release-day checklist*. In this release the refresh was free:
|
||||
`rootfs/` was already changing, so the base rebuild was forced anyway.
|
||||
|
||||
---
|
||||
|
||||
## v1.8.7 — 2026-08-25
|
||||
|
||||
Patch release, and the fastest turnaround in the series (~9 h after v1.8.6) for
|
||||
@@ -457,7 +927,7 @@ and did not require a toolkit-side change.
|
||||
**CORRECTION (2026-08-25, post-tag):** this bullet is wrong and was never
|
||||
true of the tagged tree. `pi-devbox-version` *does* print a `palace:` line
|
||||
in human mode, with live-vs-baked drift detection, degrading quietly on
|
||||
pre-v1.8.6 manifests. Nothing is open here. See the Unreleased entry above.
|
||||
pre-v1.8.6 manifests. Nothing is open here. See the v1.8.7 entry above.
|
||||
|
||||
**Resolved during this release, not left open:** the feeder `--agent`
|
||||
default behavioural hook initially looked like it might need a
|
||||
|
||||
+70
-1
@@ -277,6 +277,36 @@ ARG SOURCE_REVISION=
|
||||
# MEMPALACE_TOOLKIT_REF is consumed in Dockerfile.base; re-declared here
|
||||
# only so its intended ref lands in the label set alongside the others.
|
||||
ARG MEMPALACE_TOOLKIT_REF=main
|
||||
# ── Vendored skill provenance ─────────────────────────────────────────
|
||||
# The vendored mempalace SKILL.md is the ONLY baked artefact with no /opt
|
||||
# clone behind it: its upstream (the skillset repo) is PRIVATE, so the
|
||||
# image cannot clone it and CI cannot resolve its HEAD (see VENDORED.md).
|
||||
# Consequence through v1.8.7: the snapshot was ANONYMOUS — nothing in the
|
||||
# image or the repo recorded which skillset commit it was taken from, so
|
||||
# the only staleness check available was a hand-maintained phrase canary in
|
||||
# scripts/smoke-test.sh, which by construction can only detect "older than
|
||||
# what I remembered to pin", never "older than skillset main".
|
||||
#
|
||||
# Recording the ref costs nothing and makes the question answerable. It is
|
||||
# deliberately a plain ARG DEFAULT rather than a CI-resolved output:
|
||||
# * the value is a fact about the committed snapshot, so it belongs in
|
||||
# the tree next to it — not in a workflow that a local `docker build`
|
||||
# never runs (same reasoning as MEMPALACE_VERSION living in
|
||||
# Dockerfile.base rather than being duplicated in docker-publish.yml);
|
||||
# * CI therefore needs NO new build-arg at any of its four
|
||||
# Dockerfile.variant call sites (smoke, smoke-studio, build-variant,
|
||||
# build-variant-studio) — a plumbing change that is easy to
|
||||
# under-apply to only two of them;
|
||||
# * and it needs no credential for a private repo.
|
||||
# Bump it with scripts/vendor-mempalace-skill.sh, which refreshes the file
|
||||
# and rewrites this line together, so the pair cannot drift apart by hand.
|
||||
# This ARG lives in Dockerfile.variant ON PURPOSE: Dockerfile.base and
|
||||
# rootfs/ are both hashed into base_tag, so recording provenance here costs
|
||||
# no ~67-minute base rebuild. (scripts/check-base-hash.sh scans only
|
||||
# Dockerfile.base, so no folding into the base hash is required — nor would
|
||||
# it be correct, since this ARG changes nothing about the base's contents.)
|
||||
ARG SKILLSET_SNAPSHOT_REF=6eb20af181f0147cb8c1377f6e36a6a47a68e8e5
|
||||
|
||||
# Dockerfile.base sets description="pi-devbox — base image (variant-independent)"
|
||||
# and every variant INHERITS it, so both published images used to advertise
|
||||
# themselves on Docker Hub as the base image. A LABEL cannot branch on
|
||||
@@ -301,7 +331,8 @@ LABEL org.opencontainers.image.version="${RELEASE_TAG}" \
|
||||
se.jordbo.pi-devbox.pi-atelier-version="${PI_ATELIER_VERSION}" \
|
||||
se.jordbo.pi-devbox.mempalace-toolkit-ref="${MEMPALACE_TOOLKIT_REF}" \
|
||||
se.jordbo.pi-devbox.pi-studio-ref="${PI_STUDIO_REF}" \
|
||||
se.jordbo.pi-devbox.pi-studio-version="${PI_STUDIO_VERSION}"
|
||||
se.jordbo.pi-devbox.pi-studio-version="${PI_STUDIO_VERSION}" \
|
||||
se.jordbo.pi-devbox.skillset-snapshot-ref="${SKILLSET_SNAPSHOT_REF}"
|
||||
|
||||
# The manifest is written from GROUND TRUTH — the actual checked-out HEAD
|
||||
# of each /opt clone and the live `pi --version` — not merely the intended
|
||||
@@ -327,6 +358,34 @@ RUN set -e; \
|
||||
case "$MP_V" in [0-9]*) MP_CORE="\"${MP_V}\"" ;; *) MP_CORE='null' ;; esac; \
|
||||
STUDIO_REV='null'; \
|
||||
if [ -d /opt/pi-studio/.git ]; then STUDIO_REV="\"$(rev /opt/pi-studio)\""; fi; \
|
||||
# The vendored skill snapshot's fingerprint is MEASURED here, not passed
|
||||
# in as a build-arg, per the ground-truth rule above: SKILLSET_SNAPSHOT_REF
|
||||
# is a CLAIM about which skillset commit the file came from, while this
|
||||
# hash is what the image actually ships. Recorded together they let any
|
||||
# reader with the skillset checked out — which on this fleet is every
|
||||
# host, since all four compose stacks mount it — verify the claim at
|
||||
# RUNTIME, without CI ever needing access to the private repo. Degrades
|
||||
# to JSON null rather than failing the build if the directory is absent;
|
||||
# the smoke assertion is what turns that into a loud failure.
|
||||
#
|
||||
# Hashes the whole DIRECTORY, not just SKILL.md: a single-file hash
|
||||
# answers "did this one file change", not "is the live copy the same
|
||||
# skill" — a live checkout that added or edited a SIBLING file (a
|
||||
# reference/ doc, a helper script) would still report "identical to
|
||||
# baked snapshot" against a file-only hash. pi-extensions already ships
|
||||
# two files for exactly this reason (SKILL.md + evaluate-extension-usage.py),
|
||||
# so this is not a hypothetical. Deterministic over `find | sort`, never
|
||||
# readdir order: relative paths + per-file sha256, folded into one hash.
|
||||
# pi-devbox-version mirrors this exact pipeline over the live directory so
|
||||
# the two sides are comparable — if you change this, change that too.
|
||||
tree_sha256() { \
|
||||
( cd "$1" && find . -type f -print | LC_ALL=C sort | xargs -r sha256sum ) 2>/dev/null | sha256sum | cut -d' ' -f1; \
|
||||
}; \
|
||||
SKILL_SNAP='null'; \
|
||||
_snap_dir=/usr/local/share/pi-devbox/skills/mempalace; \
|
||||
if [ -d "$_snap_dir" ] && [ -n "$(find "$_snap_dir" -type f -print -quit)" ]; then \
|
||||
SKILL_SNAP="\"$(tree_sha256 "$_snap_dir")\""; \
|
||||
fi; \
|
||||
{ \
|
||||
echo '{'; \
|
||||
echo " \"release_tag\": \"${RELEASE_TAG}\","; \
|
||||
@@ -337,6 +396,16 @@ RUN set -e; \
|
||||
# SHAs and `pi-devbox-version` renders it with .value[0:12], which would
|
||||
# silently truncate a longer version string.
|
||||
echo " \"mempalace_version\": ${MP_CORE},"; \
|
||||
# Siblings, NOT members of components{}, for two independent reasons:
|
||||
# that map means "HEAD of a clone present in this image" and the
|
||||
# skillset is not cloned here (calling it a component would be a
|
||||
# lie a future reader would act on), and `pi-devbox-version` renders
|
||||
# every components{} value with .value[0:12] — which would truncate
|
||||
# a 64-hex sha256 into something that looks like a short commit.
|
||||
# Named `_tree_sha256`, not `_sha256`: it measures every file under the
|
||||
# vendored skill directory, not one file — see tree_sha256() above.
|
||||
echo " \"skillset_snapshot_ref\": \"${SKILLSET_SNAPSHOT_REF}\","; \
|
||||
echo " \"skillset_snapshot_tree_sha256\": ${SKILL_SNAP},"; \
|
||||
echo " \"components\": {"; \
|
||||
echo " \"pi-toolkit\": \"$(rev /opt/pi-toolkit)\","; \
|
||||
echo " \"pi-extensions\": \"$(rev /opt/pi-extensions)\","; \
|
||||
|
||||
@@ -555,6 +555,32 @@ session/docs mining; the 29 MCP tools (search, kg-query, drawer-add,
|
||||
diary-write, etc.) are wired into pi automatically by the pi-extensions
|
||||
mempalace bridge.
|
||||
|
||||
### Cross-machine agent coordination
|
||||
|
||||
When `MEMPALACE_REMOTE_URL` points at a *shared* palace, the container gets more
|
||||
than shared search: it joins an append-only coordination log (RFC 003) that other
|
||||
machines' agents can address it on — used here for design review, patch handoff
|
||||
and retraction between hosts.
|
||||
|
||||
Two container-side settings make it work:
|
||||
|
||||
| Variable | Why it matters |
|
||||
|---|---|
|
||||
| `MEMPALACE_REMOTE_URL` | selects the shared palace; unset means a purely local palace, and the log then contains only this machine's own events |
|
||||
| `MEMPALACE_PI_DEVICE` | the bridge stamps `pi@<device>` as the writer, which is the **only** way the log can tell two machines apart when both are thin clients of one palace |
|
||||
|
||||
So a container with no `MEMPALACE_PI_DEVICE` can read the log but is not
|
||||
reachable *on* it: messages addressed to a bare `pi` match nobody. Set both, or
|
||||
neither.
|
||||
|
||||
What the agent is expected to *do* with this lives in the mempalace skill
|
||||
(`~/.agents/skills/mempalace/SKILL.md`) — the mailbox query at wake-up, and the
|
||||
convention that a directed event with `status="open"` is a request owed a reply
|
||||
while a `*` broadcast owes nothing. The mechanism side (what the bridge stamps,
|
||||
and why live SSE push depends on the palace deployment's reverse proxy rather
|
||||
than on this image) is documented in the toolkit's `extensions/pi/README.md`.
|
||||
Nothing in this image polls the log on the agent's behalf.
|
||||
|
||||
## Agent skills
|
||||
|
||||
pi discovers skills under `~/.agents/skills/`. Two delivery paths feed that
|
||||
@@ -992,9 +1018,20 @@ persisted volumes survived, and pi runtime wiring is intact:
|
||||
|
||||
```bash
|
||||
./scripts/recreate-sanity-check.sh # auto-detects variant
|
||||
./scripts/recreate-sanity-check.sh --expected-version 0.79.4 # assert pi version
|
||||
./scripts/recreate-sanity-check.sh --expected-image-version 1.8.9 # assert the pi-devbox release tag
|
||||
./scripts/recreate-sanity-check.sh --expected-version 0.84.3 # assert the pi coding agent version
|
||||
```
|
||||
|
||||
Those are **two different versions**, and the flags are not interchangeable:
|
||||
`--expected-image-version` takes the pi-devbox release tag (`v` optional),
|
||||
`--expected-version` takes `pi --version`. Hand one the other's value and it
|
||||
says so by name instead of reporting a mismatch against the wrong component.
|
||||
With neither flag, both values are read from the image's own build manifest
|
||||
(`/etc/pi-devbox/build-manifest.json`): the live pi version is asserted against
|
||||
the one recorded at build time — which catches a stale `pi` in the
|
||||
`~/.pi/npm-global` volume shadowing the baked one — and the release tag is
|
||||
reported informationally.
|
||||
|
||||
If `cli_utils` is on your PATH, the `pi-devbox-sanity` wrapper runs the same
|
||||
check by short name and locates the repo automatically (override with
|
||||
`PI_DEVBOX_REPO=/path/to/pi-devbox`). Like `smoke-test.sh`, this script is
|
||||
|
||||
+5
-1
@@ -7,7 +7,11 @@ set -euo pipefail
|
||||
# so this reaches the same stream as the interactive shell the user lands
|
||||
# in). Reads the ground-truth manifest baked in Dockerfile.variant; a no-op
|
||||
# with a short stderr notice on images built before it existed.
|
||||
command -v pi-devbox-version >/dev/null 2>&1 && pi-devbox-version || true
|
||||
# `--no-skills`: this runs FIRST, before the baked skill links are created
|
||||
# below and long before the skillset deploy + devbox-skill-reconcile run at the
|
||||
# end of this script, so the skill-source section would report a pre-reconcile
|
||||
# state that is about to change. Wrong-but-plausible is worse than absent.
|
||||
command -v pi-devbox-version >/dev/null 2>&1 && pi-devbox-version --no-skills || true
|
||||
|
||||
# ── SSH ControlMaster socket dir ────────────────────────────────
|
||||
# Companion to /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf in the
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
# pi-devbox-version human-readable summary (default)
|
||||
# pi-devbox-version --json raw manifest JSON (for scripting)
|
||||
# pi-devbox-version --quiet one-line "release_tag (source_revision)" form
|
||||
# pi-devbox-version --no-skills skip the skill-source section (used at
|
||||
# container start, where it would be premature)
|
||||
#
|
||||
# EXIT STATUS
|
||||
# 0 on success. 1 if the manifest is missing (e.g. an image built before
|
||||
@@ -24,15 +26,35 @@ set -euo pipefail
|
||||
|
||||
MANIFEST=/etc/pi-devbox/build-manifest.json
|
||||
MODE="human"
|
||||
SHOW_SKILLS="yes"
|
||||
|
||||
case "${1:-}" in
|
||||
# A `case "${1:-}"` here only ever looked at the FIRST argument, so
|
||||
# `--no-skills --json` matched --no-skills, silently dropped --json, and
|
||||
# printed human text to a caller expecting JSON (a real failure: a jq
|
||||
# consumer piping that output gets a parse error, not a wrong-but-parseable
|
||||
# answer). Loop over every argument instead, and reject anything unknown
|
||||
# rather than silently ignoring it the same way.
|
||||
for _arg in "$@"; do
|
||||
case "$_arg" in
|
||||
--json) MODE="json" ;;
|
||||
--quiet|-q) MODE="quiet" ;;
|
||||
--no-skills) SHOW_SKILLS="no" ;;
|
||||
--help|-h)
|
||||
sed -n '2,20p' "$0" | sed 's/^# \?//'
|
||||
# Print the leading `#`-comment block verbatim, stopping at the first
|
||||
# non-comment line, rather than a hardcoded line range: `sed -n
|
||||
# '2,22p'` was silently truncating --help because this file has grown
|
||||
# usage lines since that range was written, and a fixed range will
|
||||
# drift again the next time a comment is added above it.
|
||||
awk 'NR==1{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "$0"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
*)
|
||||
echo "pi-devbox-version: unknown option: $_arg" >&2
|
||||
echo " try --help" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ ! -f "$MANIFEST" ]; then
|
||||
echo "pi-devbox-version: no build manifest at $MANIFEST" >&2
|
||||
@@ -105,3 +127,108 @@ fi
|
||||
|
||||
printf ' components:\n'
|
||||
jq -r '.components | to_entries[] | select(.value != null) | " \(.key): \(.value[0:12])"' "$MANIFEST"
|
||||
|
||||
# ── Which copy of each vendored skill is actually being read? ─────────
|
||||
# The image bakes fallback skills under /usr/local/share/pi-devbox/skills/,
|
||||
# but for skills the skillset repo OWNS (skillset-owned.txt) a mounted live
|
||||
# clone takes over at container start via devbox-skill-reconcile. Nothing
|
||||
# reported which copy won, so a stale baked snapshot and a current live clone
|
||||
# looked identical from inside — and on this fleet the baked mempalace copy is
|
||||
# read by NOBODY (all four compose stacks mount a workspace containing the
|
||||
# skillset), which is exactly the sort of fact that should be visible rather
|
||||
# than reasoned about. Same "drift detected" shape as the pi/palace lines
|
||||
# above: what is live, annotated with what was baked, when they disagree.
|
||||
#
|
||||
# Skipped with --no-skills at container start (entrypoint-user.sh calls this
|
||||
# FIRST, before the baked links exist and long before the skillset deploy and
|
||||
# reconcile run last), because a section that is accurate only after boot
|
||||
# finishes is worse than no section at all.
|
||||
BAKED_SKILLS=/usr/local/share/pi-devbox/skills
|
||||
SKILLS_DIR="${HOME:-/home/developer}/.agents/skills"
|
||||
|
||||
if [ "$SHOW_SKILLS" = "yes" ] && [ -d "$BAKED_SKILLS" ] && [ -d "$SKILLS_DIR" ]; then
|
||||
# Recorded provenance of the vendored mempalace snapshot (absent on images
|
||||
# built before this existed — `// empty` so a JSON null never prints as the
|
||||
# 4-char string "null", the same trap noted for mempalace_version above).
|
||||
# `_tree_sha256`, not `_sha256`: it is a hash over every file in the
|
||||
# vendored skill DIRECTORY (see tree_sha256() below), not one file, because
|
||||
# a single-file hash reports "identical" against a live checkout that added
|
||||
# or edited a sibling file — pi-extensions already ships two files, so this
|
||||
# is not hypothetical.
|
||||
snap_ref=$(jq -r '.skillset_snapshot_ref // empty' "$MANIFEST")
|
||||
snap_sha=$(jq -r '.skillset_snapshot_tree_sha256 // empty' "$MANIFEST")
|
||||
# Same pipeline Dockerfile.variant uses to measure the baked directory at
|
||||
# build time: relative paths in `find | sort` order, each hashed, the whole
|
||||
# listing folded into one sha256. Keep the two definitions identical — they
|
||||
# run in different processes (image build vs. this container) and are
|
||||
# meaningless to compare unless they agree byte-for-byte on the algorithm.
|
||||
tree_sha256() {
|
||||
( cd "$1" && find . -type f -print | LC_ALL=C sort | xargs -r sha256sum ) 2>/dev/null | sha256sum | cut -d' ' -f1
|
||||
}
|
||||
|
||||
# Iterate the baked tree rather than a hardcoded name list, so vendoring a
|
||||
# fourth skill needs no edit here. The header prints only if the tree is
|
||||
# non-empty, so this can never emit a dangling "skills:" label.
|
||||
_printed_header="no"
|
||||
for _dir in "$BAKED_SKILLS"/*/; do
|
||||
[ -d "$_dir" ] || continue
|
||||
if [ "$_printed_header" = "no" ]; then
|
||||
printf ' skills:\n'
|
||||
_printed_header="yes"
|
||||
fi
|
||||
_name=$(basename "$_dir")
|
||||
_link="$SKILLS_DIR/$_name"
|
||||
|
||||
if [ ! -e "$_link" ]; then
|
||||
printf ' %-22s not linked\n' "$_name"
|
||||
continue
|
||||
fi
|
||||
|
||||
_target=$(readlink -f "$_link" 2>/dev/null || echo "$_link")
|
||||
case "$_target" in
|
||||
"$BAKED_SKILLS"/*|"$BAKED_SKILLS")
|
||||
printf ' %-22s baked\n' "$_name"
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
# Outside the baked tree: a mounted skillset clone, or a user override.
|
||||
# The link target is <repo>/skills/<name>, so the repo root is two up.
|
||||
# Everything here is guarded: this script runs on the container-start path
|
||||
# and must never fail, and `set -e` is in force.
|
||||
_root=$(cd "$_target/../.." 2>/dev/null && pwd) || _root=""
|
||||
_head=""
|
||||
if [ -n "$_root" ]; then
|
||||
_head=$(git -C "$_root" rev-parse HEAD 2>/dev/null || echo "")
|
||||
fi
|
||||
_where="live ${_root:-$_target}"
|
||||
[ -n "$_head" ] && _where="$_where @ ${_head:0:7}"
|
||||
|
||||
# For the one skill whose baked fingerprint we recorded, say plainly
|
||||
# whether the live copy differs from what shipped. This is the check CI
|
||||
# cannot perform (the skillset is private) and the container can, free.
|
||||
# Hash the whole live DIRECTORY with the same tree_sha256() used to
|
||||
# measure the baked one in Dockerfile.variant — a SKILL.md-only compare
|
||||
# would silently ignore a changed or added sibling file.
|
||||
_live_sha=""
|
||||
if [ -n "$snap_sha" ] && [ "$_name" = "mempalace" ] && [ -d "$_target" ]; then
|
||||
_live_sha=$(tree_sha256 "$_target")
|
||||
fi
|
||||
if [ -z "$_live_sha" ]; then
|
||||
printf ' %-22s %s\n' "$_name" "$_where"
|
||||
elif [ "$_live_sha" = "$snap_sha" ]; then
|
||||
printf ' %-22s %s (identical to baked snapshot)\n' "$_name" "$_where"
|
||||
elif [ -n "$_head" ] && [ "$_head" = "$snap_ref" ]; then
|
||||
# Same commit, different bytes — i.e. uncommitted edits in the live
|
||||
# checkout. Distinguished from plain drift because otherwise the line
|
||||
# reads as a self-contradiction ("@ c04cd15 ... baked snapshot c04cd15
|
||||
# — live copy differs") and a reader would suspect the tool, not the
|
||||
# working tree.
|
||||
printf ' %-22s %s \033[33m(baked snapshot %s + uncommitted edits)\033[0m\n' \
|
||||
"$_name" "$_where" "${snap_ref:0:7}"
|
||||
else
|
||||
printf ' %-22s %s \033[33m(baked snapshot %s — live copy differs)\033[0m\n' \
|
||||
"$_name" "$_where" "${snap_ref:0:7}"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
@@ -39,6 +39,33 @@ its skill file needed baking.
|
||||
*different* skill, `opencode-mempalace-bridge`), so there is no public
|
||||
package source to copy from. This snapshot is refreshed manually per release.
|
||||
|
||||
**Refresh it with `scripts/vendor-mempalace-skill.sh <skillset-root>`, not
|
||||
`cp`.** Because the image cannot clone the private upstream, the snapshot used
|
||||
to be *anonymous* — nothing recorded which skillset commit the bytes came
|
||||
from, so the only staleness check possible was a hand-maintained phrase canary
|
||||
in `scripts/smoke-test.sh`, which by construction detects "older than the
|
||||
phrase I remembered to pin", never "older than skillset main". Two facts now
|
||||
travel with the file:
|
||||
|
||||
| Fact | Where | Kind |
|
||||
|---|---|---|
|
||||
| `ARG SKILLSET_SNAPSHOT_REF` in `Dockerfile.variant` | manifest `skillset_snapshot_ref` + OCI label `se.jordbo.pi-devbox.skillset-snapshot-ref` | a **claim** about which commit these bytes are |
|
||||
| `sha256sum` of this file, measured in the manifest layer | manifest `skillset_snapshot_sha256` | the bytes that **actually shipped** |
|
||||
|
||||
The script writes both together, refuses when the upstream file has
|
||||
uncommitted modifications (no commit describes those bytes), and
|
||||
`--check` verifies the claim against a real clone. Deliberately an `ARG`
|
||||
default rather than a CI-resolved value: no credential for a private repo, no
|
||||
change at any of the four `Dockerfile.variant` build call sites, and a local
|
||||
`docker build` records the same thing CI does.
|
||||
|
||||
Verifying "is this snapshot current?" is **not** a CI job and was deliberately
|
||||
not made one — see the Unreleased CHANGELOG entry for why (private repo;
|
||||
another repo's branch must not be able to fail this build; and the artefact it
|
||||
would guard is read by no host on this fleet). The check belongs where the
|
||||
skillset actually is: `vendor-mempalace-skill.sh --check` for a maintainer,
|
||||
and `pi-devbox-version`'s `skills:` section for an agent inside a container.
|
||||
|
||||
## Runtime precedence (v1.8.5+)
|
||||
|
||||
The baked links are created **early** in `entrypoint-user.sh` (before pi-deploy,
|
||||
@@ -59,6 +86,21 @@ repoints the links for skills the **skillset owns**, listed one per line in
|
||||
3. **baked snapshot** — everything else, and every skill when no skillset is
|
||||
mounted
|
||||
|
||||
**Which one won is now reportable from inside the container:**
|
||||
`pi-devbox-version` prints a `skills:` section naming, per vendored skill,
|
||||
`baked` or `live <repo> @ <sha>` — and for `mempalace` whether that live copy is
|
||||
identical to the baked fingerprint, at the same commit but with uncommitted
|
||||
edits, or genuinely divergent. Before that, a stale baked snapshot and a current
|
||||
live clone were indistinguishable from inside, which is how the freshness of
|
||||
this file went unexamined for three releases. The section is suppressed with
|
||||
`--no-skills` on the container-start banner, because `entrypoint-user.sh` prints
|
||||
the version *before* the links exist and long before the reconcile below runs.
|
||||
|
||||
On this fleet, precedence 2 wins for `mempalace` on **every** host — all four
|
||||
compose stacks mount a workspace containing the skillset — so the baked copy is
|
||||
exercised only by CI and by a hypothetical no-mount container. Worth
|
||||
remembering before spending effort on its freshness.
|
||||
|
||||
Ownership is per-skill on purpose: `pi-extensions`' authoritative source is the
|
||||
package repo (copied over the snapshot at build), and `skillset` carries a
|
||||
downstream copy that can lag, so handing it to the clone would *regress* the
|
||||
@@ -72,15 +114,31 @@ mounted, plus a fabricated-skillset run of the reconciler).
|
||||
|
||||
cp <pi-extensions-pkg>/skill/SKILL.md pi-extensions/SKILL.md
|
||||
cp <pi-extensions-pkg>/skill/evaluate-extension-usage.py pi-extensions/
|
||||
cp <skillset>/skills/mempalace/SKILL.md mempalace/SKILL.md
|
||||
|
||||
Copy each snapshot **from its owner in the table above** — `pi-extensions` from
|
||||
the package repo's `skill/` (since `a7f3044` co-located it there; `skillset`
|
||||
also carries a copy, but it is a downstream duplicate and can lag), and
|
||||
`mempalace` from `skillset`. Copying `pi-extensions` from `skillset` would
|
||||
regress the snapshot to whatever that repo last mirrored.
|
||||
Copy `pi-extensions` **from its owner in the table above** — the package
|
||||
repo's `skill/` (since `a7f3044` co-located it there; `skillset` also carries a
|
||||
copy, but it is a downstream duplicate and can lag). Copying `pi-extensions`
|
||||
from `skillset` would regress the snapshot to whatever that repo last mirrored.
|
||||
|
||||
Snapshot provenance at last refresh: skillset `670f7f1`, pi-extensions pkg `e73cb9f`.
|
||||
`mempalace` is **not** refreshed by `cp` — see the *Freshness model* section
|
||||
above: `scripts/vendor-mempalace-skill.sh <skillset-root>` is the only thing
|
||||
that should ever touch that snapshot, because a bare copy can update the bytes
|
||||
without updating the ref that claims to describe them, which produces a
|
||||
manifest that confidently lies.
|
||||
|
||||
Neither vendored skill has a hand-maintained "last refreshed at" line here on
|
||||
purpose — one previously existed (skillset `670f7f1`, pi-extensions pkg
|
||||
`e73cb9f`) and went stale within hours, because nothing forced it to move
|
||||
when the ARGs did. `670f7f1` is now a cautionary example rather than a fact
|
||||
worth recording: it is the commit that told agents to hand-stamp `added_by`,
|
||||
which a later skillset commit (and the pi-devbox edge stamper) withdrew — so a
|
||||
reader trusting that line would have been pointed at superseded guidance.
|
||||
Both facts it tried to capture now live somewhere that cannot drift by hand:
|
||||
|
||||
| Fact | Where |
|
||||
|---|---|
|
||||
| which skillset commit `mempalace`'s bytes came from | `ARG SKILLSET_SNAPSHOT_REF` (Dockerfile.variant) + `skillset_snapshot_ref` in `build-manifest.json`, written *only* by `vendor-mempalace-skill.sh` |
|
||||
| which pi-extensions package commit was vendored | `ARG PI_EXTENSIONS_REF` (Dockerfile.variant, CI-resolved to a 40-hex commit) → OCI label `se.jordbo.pi-devbox.pi-extensions-ref` and `build-manifest.json`'s `components.pi-extensions`, both read from the actual `/opt/pi-extensions` checkout, not from intent |
|
||||
|
||||
When you refresh the `mempalace` snapshot, also update the phrase asserted by
|
||||
the "mempalace skill snapshot is current" smoke test — it deliberately pins the
|
||||
|
||||
@@ -41,6 +41,21 @@ Run these immediately when a session begins, before responding to the user:
|
||||
mempalace_kg_query(entity="<project_or_person>")
|
||||
```
|
||||
|
||||
4. **Check your mailbox.** Just run it — an empty result is a fine answer and
|
||||
costs one call. Do not try to decide first whether coordination "applies to
|
||||
you"; that test is what used to be wrong here (see *Cross-Machine
|
||||
Coordination* below):
|
||||
```
|
||||
mempalace_event_list(to_agent="<harness>@<device>", status="open")
|
||||
```
|
||||
This is a candidate list, not a to-do list — `status` never changes after an
|
||||
event is written, so finished asks keep matching. Subtract the ones you have
|
||||
already answered using the rule in *What you actually owe*, below.
|
||||
Another machine may have asked you something, or corrected something you are
|
||||
about to rely on. This costs one call and is the only way you will find out:
|
||||
nothing pushes an event into your session unless your bridge delivers it for
|
||||
you, and if it does you will already have seen it before reading this.
|
||||
|
||||
Do NOT announce this to the user. Just do it silently to orient yourself.
|
||||
|
||||
### Temporal grounding — compute time deltas, don't guess
|
||||
@@ -340,6 +355,176 @@ mempalace_kg_invalidate(subject="...", predicate="...", object="...", ended="<to
|
||||
mempalace_kg_add(subject="...", predicate="...", object="...", valid_from="<today>")
|
||||
```
|
||||
|
||||
## Cross-Machine Coordination — the logstream
|
||||
|
||||
The palace stores what you *know*. The logstream (`mempalace_event_*`,
|
||||
`mempalace_artifact_*`) carries what you want to *say to another agent* —
|
||||
delegation, review, patch handoff, retraction. It is the only channel on which
|
||||
another machine can reach you.
|
||||
|
||||
**Does this apply to you at all? Do not use `mempalace_mesh_peers` to decide.**
|
||||
It answers a different question than it appears to. A shared palace can be
|
||||
*hub-and-spoke* — many machines as thin clients of one central replica — and
|
||||
then `mesh_peers` reports `peers: []` because there are no peer *replicas*,
|
||||
even while four machines are actively writing to the same log. Measured on this
|
||||
fleet: `peers: []`, one replica authoring every event from every machine. An
|
||||
earlier version of this section told you to read `mesh_peers` and skip the
|
||||
mailbox when it came back empty, which disabled the mailbox on precisely the
|
||||
fleet it was written for.
|
||||
|
||||
The honest discriminators, cheapest first: **just run the mailbox query** (empty
|
||||
is a fine answer); check whether `MEMPALACE_REMOTE_URL` is set, which is what
|
||||
actually selects a shared palace; or look for any event whose `from_agent` is
|
||||
not you. On a solitary palace the event tools still work — you are writing to
|
||||
yourself and your mailbox stays empty. That is not a fault to debug.
|
||||
|
||||
**It is a durable log, not a bus — nobody is "listening".** Events are appended
|
||||
and persist; there is no subscription, no delivery window, and nothing is lost
|
||||
by being offline when one is written. A message waits indefinitely for you, and
|
||||
your reply waits just as patiently for a sender who has since gone away. Machines
|
||||
in a fleet are rarely awake at the same time, which is exactly why this is a log
|
||||
and not a chat.
|
||||
|
||||
**Agent name is the only identity the log has.** Depending on deployment, every
|
||||
client may share one `origin_replica` — on the fleet this skill was written for,
|
||||
all machines are thin MCP clients of a single central replica, so `origin_replica`
|
||||
is identical for every event and cannot tell two machines apart. `from_agent` /
|
||||
`to_agent` carry the whole distinction, which is why the `<harness>@<device>`
|
||||
stamping in *Provenance is stamped for you* is load-bearing here and not mere
|
||||
tidiness.
|
||||
|
||||
### Reading your mailbox
|
||||
|
||||
```
|
||||
mempalace_event_list(to_agent="<harness>@<device>", status="open")
|
||||
```
|
||||
|
||||
- `to_agent=<you>` **also matches `*` broadcasts**, so one call covers both. No
|
||||
second query needed.
|
||||
- `status="open"` narrows the mailbox to what a sender *said was an ask at the
|
||||
time of writing* — that is all it can do. It is a good first filter (on a real
|
||||
stream it cut 5 events to 2), but it is **not** a list of what you owe, and it
|
||||
never shrinks as you work. Treating it as owed-ness is the mistake this
|
||||
section previously made: an earlier draft cited "5 unfiltered, exactly 1
|
||||
filtered — the one that needed a reply" as proof the filter tracked
|
||||
obligation. It did not. That single result was an event which had *already
|
||||
been acked* half an hour earlier; the filter looked decisive only because the
|
||||
stream happened to contain one directed `open` event. **Unfiltered mailboxes
|
||||
train you to ignore them — and so does a filter that keeps showing you
|
||||
finished work.**
|
||||
- To resume where you left off, use `since_event_id`, **never**
|
||||
`since_created_at`. A timestamp cursor permanently skips an event that synced
|
||||
in late — it is a time window ("what happened today"), not a cursor.
|
||||
- Read `metadata` before acting: senders put the load-bearing specifics there
|
||||
(which host verified what, which run failed, what a change retracts).
|
||||
|
||||
### The ack contract — the sender declares whether a reply is owed
|
||||
|
||||
An obligation you never agreed to is noise, so the sender states it:
|
||||
|
||||
| Sender writes | Means | Recipient owes |
|
||||
|---|---|---|
|
||||
| `to_agent="<specific agent>"` + `status="open"` | an ask | an ack or a reply (the event itself keeps matching forever — see below) |
|
||||
| `to_agent="*"` (any status) | broadcast FYI | nothing |
|
||||
| any other status (`ready`, `applied`, `blocked`, …) | a statement of fact | nothing |
|
||||
|
||||
Ack with `mempalace_event_ack(event_id=…, from_agent="<you>", status=…)`. It
|
||||
**appends a new event** and never mutates the original; the correlation id is
|
||||
copied for you, and `metadata.ack_of` is set to the event you answered.
|
||||
|
||||
#### What you actually owe — derive it, do not read it off `status`
|
||||
|
||||
The log is append-only and `status` is written **once**, so it is an honest
|
||||
statement about an item *at the moment it was written* and nothing more. It is
|
||||
not mutable state, and asking it to carry mutable state is what breaks:
|
||||
acking appends a new event and changes nothing about the old one, so **a
|
||||
directed `open` event matches your mailbox query forever, answered or not.**
|
||||
Nothing is ever "dismissed" — which also means a deferred ask cannot be
|
||||
accidentally lost, only that you must compute what is outstanding:
|
||||
|
||||
```
|
||||
candidates = mempalace_event_list(to_agent="<you>", status="open")
|
||||
mine = mempalace_event_list(from_agent="<you>")
|
||||
```
|
||||
|
||||
A candidate is **answered** when one of your own events
|
||||
|
||||
1. has a **higher `seq`** than the candidate, and
|
||||
2. joins to it — `metadata.ack_of == candidate.id` (exact, written for you by
|
||||
`event_ack`) or the same `correlation_id` (the fallback), and
|
||||
3. carries a **terminal** status: `applied`, `superseded`, `failed`, `blocked`.
|
||||
|
||||
Everything else is still owed. Two calls, constant cost.
|
||||
|
||||
**Compare `seq`, never `created_at`** — the same reason you resume with
|
||||
`since_event_id`. Without the ordering test, one terminal reply would suppress
|
||||
every later ask on the same `correlation_id` for good; verified on a live thread
|
||||
where a `ready` reply at `seq` 16 sits *before* the request at `seq` 17 that it
|
||||
obviously cannot have answered.
|
||||
|
||||
**On a real mesh, compare `hlc` instead.** `seq` is *replica-local*: it equals
|
||||
`origin_seq` today only because a single replica authors events for every
|
||||
machine. Enrol a second replica and a late-syncing peer event gets a late local
|
||||
`seq`, so two replicas can order the same pair differently and derive different
|
||||
owed-sets from the same log. Every event already carries `hlc`
|
||||
(`<millis>-<counter>-<replica_id>`), which is total and causally consistent.
|
||||
So: compare `seq` while `mempalace_mesh_peers` reports no peers, `hlc` once it
|
||||
reports any, and `created_at` never. (This is a legitimate use of `mesh_peers` —
|
||||
choosing an ordering key — not the discredited gate on *whether* to read your
|
||||
mailbox at all.)
|
||||
|
||||
**The failure directions are not symmetric, which is why this is safe to get
|
||||
slightly wrong.** Local-`seq` skew can make an already-answered item *resurface*
|
||||
as owed: noise, self-correcting, and visible. A timestamp comparison can
|
||||
*suppress an unanswered ask forever*: silent and permanent. So if you ever see an
|
||||
item you know you answered come back, do **not** "fix" it by reaching for
|
||||
`created_at` — you would be trading the safe failure for the dangerous one.
|
||||
|
||||
This also supplies the "taken, not finished" state that looked missing:
|
||||
`claimed` and `ready` are deliberately **not** terminal, so work you have picked
|
||||
up keeps resurfacing until you close it out. No extra convention, no new field.
|
||||
|
||||
Two consequences worth internalising:
|
||||
|
||||
- **"Seen, not doing it" is a legitimate ack** — `status="blocked"` or
|
||||
`"superseded"` plus the reason. Silence is not, and it is not merely rude:
|
||||
with no terminal event of yours to join to, the ask stays in the owed set
|
||||
indefinitely and there is nothing anyone can do about it from the other end.
|
||||
- **Nothing expires, and it should not.** An `open` with no terminal reply is
|
||||
still live by definition, and the finished threads are valuable history. If
|
||||
content is genuinely perishable ("do not push to main for the next hour"), say
|
||||
so in `metadata.expires_at` — metadata is stored verbatim — and honour it as a
|
||||
hint when reading. An old `open` that the derivation still counts as owed is a
|
||||
signal, not garbage: it means somebody asked and nobody answered.
|
||||
|
||||
### Writing to another machine
|
||||
|
||||
- **Address the stamped name you actually saw** in a `from_agent` field, e.g.
|
||||
`pi@tor-ms22`. A bare `pi` reaches nobody's mailbox once stamping is live, and
|
||||
older events in the log still carry bare names — do not copy them.
|
||||
- **Use `status="open"` only when you truly need an answer.** It places an
|
||||
obligation on another machine.
|
||||
- **Never broadcast an ask.** `to_agent="*"` + `status="open"` obliges everyone
|
||||
and therefore no one.
|
||||
- **Always set a `correlation_id` on a directed `open`,** and reply with the
|
||||
same one. It is not just for reconstructing a conversation later: it is the
|
||||
join the owed-set derivation depends on. An uncorrelated ask can only ever be
|
||||
closed by an `event_ack` (which sets `ack_of` for you) — a plain reply cannot
|
||||
be matched to it at all.
|
||||
- **Corrections are new events, never edits.** Say explicitly what you retract
|
||||
and name the id — drawer or event — that carried the withdrawn claim.
|
||||
- **Put a retraction where the reader will look.** An event reaches a live agent;
|
||||
a *drawer* is what a future semantic search finds. If you filed advice as a
|
||||
drawer and later withdraw it, file the withdrawal as a drawer too — otherwise
|
||||
the next agent finds your original confident advice and no trace of the
|
||||
correction. (This is a real incident, not a hypothetical.)
|
||||
- **Hand over exact content as an artifact**, not prose: `mempalace_artifact_put`
|
||||
or `mempalace_patch_submit` store bytes with a sha256, and the event references
|
||||
the id. Never paste a diff into a body and hope it survives.
|
||||
- **Waiting on a specific reply?** `mempalace_event_wait` blocks with backoff —
|
||||
do not poll `event_list` in a loop. A timeout there is a normal result, not an
|
||||
error.
|
||||
|
||||
## Palace Structure
|
||||
|
||||
### Wings
|
||||
@@ -366,7 +551,7 @@ Zechner's pi-coding-agent). Implications:
|
||||
When the palace is **central** (shared across machines), these further things apply:
|
||||
|
||||
- **Check which machine a conversation came from.** Transcripts are fed per device, so `source_path` reads `…/mempalace-feed/<device>/pi_<uuid>.jsonl` while the displayed `source_file` is only the basename. One search can legitimately return hits from several machines at once — look at the device segment before attributing a decision to *this* project.
|
||||
- **Provenance is stamped for you — leave it alone.** Drawers carry `device` and `agent_kind` metadata (plus `device_source`/`agent_kind_source` recording *how* each was determined, so an inference is never mistaken for a fact). You do **not** set these, and you no longer set `added_by` either: the pi bridge defaults the writer field to `<harness>@<device>` on `add_drawer`/`checkpoint`/`mine`/`event_append`/`artifact_put`, and prefixes diary entries with `HOST:<device>|`, from host-supplied `$MEMPALACE_PI_DEVICE`. RFC 001 §7.3.2 ranks "agent stamps it via a skill instruction" as the *worst possible* place for exactly the reason you would expect — it is per-call boilerplate that gets forgotten, and it did: the agent who wrote the previous version of this bullet then filed its own provenance drawer as `added_by=checkpoint`. Two things remain yours: pass `source_drawer_id` on `kg_add` (triples have no provenance field, so that pointer is the only path back to a device), and pass an explicit `added_by` **only** when deliberately filing on behalf of another device. Never invent values for `device`/`agent_kind`/`origin_device` — a fabricated value is worse than a blank, because it silently corrupts a future merge.
|
||||
- **Provenance is stamped for you — leave it alone.** Drawers carry `device` and `agent_kind` metadata (plus `device_source`/`agent_kind_source` recording *how* each was determined, so an inference is never mistaken for a fact). You do **not** set these, and you no longer set `added_by` either: the pi bridge defaults the writer field to `<harness>@<device>` on `add_drawer`/`checkpoint`/`mine`/`event_append`/`artifact_put`, and prefixes diary entries with `HOST:<device>|`, from host-supplied `$MEMPALACE_PI_DEVICE`. RFC 001 §7.3.2 ranks "agent stamps it via a skill instruction" as the *worst possible* place for exactly the reason you would expect — it is per-call boilerplate that gets forgotten, and it did: the agent who wrote the previous version of this bullet then filed its own provenance drawer as `added_by=checkpoint`. **Confirm the bridge in your image actually stamps before trusting it:** the extension is baked at image build time, so a container on an image older than the stamping commit (pi-devbox < v1.8.7) stamps nothing while still satisfying both gates — the env vars are set and the code is simply absent. Check with `grep -c MEMPALACE_PI_DEVICE "$(readlink -f ~/.pi/agent/extensions/mempalace.ts)"`; zero means keep passing `added_by="<harness>@<device>"` and a manual `HOST:<device>|` diary prefix until the container is recreated on a newer image. Two things remain yours: pass `source_drawer_id` on `kg_add` (triples have no provenance field, so that pointer is the only path back to a device), and pass an explicit `added_by` **only** when deliberately filing on behalf of another device. Never invent values for `device`/`agent_kind`/`origin_device` — a fabricated value is worse than a blank, because it silently corrupts a future merge.
|
||||
- **Metadata is invisible to search — so check the text, not the fields.** `search` results are built from a fixed key list and `diary_read` returns content, so neither ever shows `device`/`added_by`. Only `mempalace_get_drawer` reveals them. This is why diary entries carry an in-text `HOST:<device>` marker: it is the only attribution a reader actually sees. **A diary entry with no `HOST:` marker predates the convention and may be from any machine — do not assume it is this one's history.**
|
||||
- **Mined drawers carry the MINE date, not the session date.** When history is imported, or re-mined on the palace host, `filed_at`/`created_at` is the *import* time — so sorting by them does not give chronological order. Real session time is recoverable from the UUIDv7 in `pi_<uuid>.jsonl`: the first 12 hex digits are milliseconds since the epoch (and UUIDv7 sorts lexicographically in time order, so a plain filename sort is already chronological). Agent-authored drawers and diaries have no such backdoor — for those `filed_at` is the only chronology, which is why it must never be restamped.
|
||||
- **Beware the timezone mismatch when you combine those.** Palace `filed_at`/`created_at` are naive timestamps in the palace host's local time, while a UUIDv7 decodes to UTC. Comparing them directly introduces a silent offset (2 h for a CEST host). Normalise before drawing conclusions about ordering.
|
||||
@@ -413,4 +598,6 @@ Entity-relationship triples with temporal validity. Query with `mempalace_kg_que
|
||||
- **Don't mine .git directories or node_modules.** The CLI miner respects .gitignore by default.
|
||||
- **Don't create duplicate drawers.** Use `mempalace_check_duplicate` before adding manually.
|
||||
- **Don't treat the palace as a task list.** It's for knowledge and context, not todos.
|
||||
- **Don't invent provenance metadata, and don't hand-stamp it either.** An earlier version of this list told you to set `added_by="<harness>@<device>"` by hand; that instruction has been withdrawn, because RFC 001 §7.3.2 places provenance at the client/server boundary and the pi bridge now does it uniformly (see *Provenance is stamped for you* above). DO NOT invent values for the palace's own metadata fields (`device`, `agent_kind`, `origin_device`): those are stamped by infrastructure that also records *how* each was determined, and a fabricated value is worse than none because it silently corrupts a future merge. DO pass `source_drawer_id` on `kg_add`. And never put a machine name in a diary's `agent_name` — it becomes the wing name and hides your entries from `diary_read`.
|
||||
- **Don't broadcast an ask, and don't leave one unanswered.** On a shared palace, `to_agent="*"` + `status="open"` obliges every machine and therefore none of them. And don't expect acking to tidy your mailbox: `status` is immutable, so the event keeps matching either way — what a terminal reply buys you is that the *derived* owed set (see *What you actually owe*) stops counting it. Leave asks unanswered and that set only grows, until everyone learns to stop looking. "Seen, not doing it" is a complete answer — silence is not.
|
||||
- **Don't assume you would have heard.** Nothing pushes another machine's message into your session. If you did not run the mailbox query at wake-up, a correction addressed to you by name can sit unread while you confidently rebuild the thing it warned you about.
|
||||
- **Don't invent provenance metadata, and don't hand-stamp it either.** An earlier version of this list told you to set `added_by="<harness>@<device>"` by hand; that instruction has been withdrawn, because RFC 001 §7.3.2 places provenance at the client/server boundary and the pi bridge now does it uniformly (see *Provenance is stamped for you* above) — but the withdrawal only holds where the bridge is live, so run the one-line check in that bullet first; on an older image hand-stamping is still the only signal a hand-filed drawer gets. DO NOT invent values for the palace's own metadata fields (`device`, `agent_kind`, `origin_device`): those are stamped by infrastructure that also records *how* each was determined, and a fabricated value is worse than none because it silently corrupts a future merge. DO pass `source_drawer_id` on `kg_add`. And never put a machine name in a diary's `agent_name` — it becomes the wing name and hides your entries from `diary_read`.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# Runtime post-recreate verification for pi-devbox.
|
||||
#
|
||||
# Verifies that after `docker compose up -d --force-recreate`:
|
||||
# - The new image is actually live (pi version matches, when an expected
|
||||
# version is supplied — see the version note below)
|
||||
# - The new image is actually live (both the pi version and — when asked —
|
||||
# the pi-devbox image release tag; see the two version notes below)
|
||||
# - Persisted named volumes survived (~/.pi config, shell history, zoxide,
|
||||
# nvim data, uv cache, ssh-local)
|
||||
# - pi runtime wiring is intact: keybindings symlink, AGENTS.md symlink,
|
||||
@@ -25,13 +25,33 @@
|
||||
# the pi-devbox repo (which a maintainer already has for CI builds). A plain
|
||||
# `docker pull` consumer is not the audience and will not have this file.
|
||||
#
|
||||
# Version note: pi's version is resolved from `latest` at CI build time and is
|
||||
# NOT pinned to a concrete value in Dockerfile.variant (ARG PI_VERSION=latest).
|
||||
# So unlike opencode-devbox, this script cannot self-derive an expected version
|
||||
# from the Dockerfile. Pass --expected-version to assert a match; without it the
|
||||
# live pi version is reported as an informational WARN, not a failure.
|
||||
# TWO DIFFERENT VERSIONS, TWO DIFFERENT FLAGS. This distinction has already
|
||||
# cost a release day, so it is spelled out here and in AGENTS.md step 4:
|
||||
#
|
||||
# Usage: ./scripts/recreate-sanity-check.sh [--expected-version X.Y.Z] [--variant studio|plain]
|
||||
# --expected-version the PI CODING AGENT version, e.g. 0.84.3
|
||||
# (`pi --version`; pinned as ARG PI_VERSION in
|
||||
# Dockerfile.variant, which CI reads as the source
|
||||
# of truth)
|
||||
# --expected-image-version the PI-DEVBOX IMAGE release tag, e.g. 1.8.9 or
|
||||
# v1.8.9 (the `release_tag` baked into
|
||||
# /etc/pi-devbox/build-manifest.json)
|
||||
#
|
||||
# Passing a release tag to --expected-version used to report
|
||||
# "pi version mismatch: expected 1.8.8, got 0.84.3" — an accusation aimed at
|
||||
# the wrong component, on the last gate of a release. Both flags now detect
|
||||
# being handed the other one's value and say so instead.
|
||||
#
|
||||
# Neither flag is required. Both values are derivable from the image's own
|
||||
# build manifest, so by default the script asserts the LIVE pi version against
|
||||
# the version recorded at build time — which is not a tautology: a stale
|
||||
# `pi` in the ~/.pi/npm-global volume can shadow the baked one, exactly the
|
||||
# way a stale npm:pi-atelier can (see the packages[] check below). Pass the
|
||||
# flags when you want an assertion against a value you name yourself, which
|
||||
# is what a release checklist wants.
|
||||
#
|
||||
# Usage: ./scripts/recreate-sanity-check.sh [--expected-version X.Y.Z]
|
||||
# [--expected-image-version X.Y.Z]
|
||||
# [--variant studio|plain]
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 all checks passed
|
||||
@@ -41,22 +61,61 @@
|
||||
set -euo pipefail
|
||||
|
||||
EXPECTED_VERSION=""
|
||||
EXPECTED_IMAGE_VERSION=""
|
||||
VARIANT=""
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
MANIFEST=/etc/pi-devbox/build-manifest.json
|
||||
|
||||
# Parse arguments
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: recreate-sanity-check.sh [--expected-version X.Y.Z]
|
||||
[--expected-image-version X.Y.Z]
|
||||
[--variant studio|plain]
|
||||
|
||||
--expected-version pi coding agent version, e.g. 0.84.3 (`pi --version`)
|
||||
--expected-image-version pi-devbox image release tag, e.g. 1.8.9 or v1.8.9
|
||||
--variant studio|plain (auto-detected when omitted)
|
||||
|
||||
These are two different versions. Both are read from the image's own build
|
||||
manifest when the corresponding flag is omitted.
|
||||
EOF
|
||||
}
|
||||
|
||||
# Parse arguments. Every flag takes a value, so reject a missing one rather
|
||||
# than swallowing the next flag as if it were the value.
|
||||
need_value() {
|
||||
case "${2:-}" in
|
||||
""|-*)
|
||||
echo "$1 requires a value" >&2
|
||||
usage
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--expected-version)
|
||||
need_value "$@"
|
||||
EXPECTED_VERSION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--expected-image-version)
|
||||
need_value "$@"
|
||||
EXPECTED_IMAGE_VERSION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--variant)
|
||||
need_value "$@"
|
||||
VARIANT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "usage: $0 [--expected-version X.Y.Z] [--variant studio|plain]" >&2
|
||||
echo "unknown option: $1" >&2
|
||||
usage
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -67,6 +126,19 @@ pass() { echo " ✓ $1"; }
|
||||
fail() { echo " ✗ $1" >&2; FAILED=$((FAILED + 1)); }
|
||||
warn() { echo " ⚠ $1" >&2; }
|
||||
|
||||
# Read one top-level field from the build manifest, or print nothing. The
|
||||
# manifest is the image's own ground truth (written at `docker build` time by
|
||||
# Dockerfile.variant), so it needs no checkout and no network. Absent on an
|
||||
# image built before it existed, hence every caller treats "" as unknown.
|
||||
manifest_field() {
|
||||
[ -f "$MANIFEST" ] || return 0
|
||||
command -v jq >/dev/null 2>&1 || return 0
|
||||
jq -r --arg k "$1" '.[$k] // empty' "$MANIFEST" 2>/dev/null || true
|
||||
}
|
||||
# Release tags are written with a leading v in the manifest and quoted without
|
||||
# one in checklists; compare on the bare number so both spellings work.
|
||||
strip_v() { printf '%s' "${1#v}"; }
|
||||
|
||||
# Auto-detect variant if not provided. The studio variant vendors pi-studio to
|
||||
# /opt/pi-studio; the plain variant does not.
|
||||
if [ -z "$VARIANT" ]; then
|
||||
@@ -86,21 +158,59 @@ else
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "-- pi version --"
|
||||
MANIFEST_PI_VERSION=$(manifest_field pi_version)
|
||||
MANIFEST_RELEASE_TAG=$(manifest_field release_tag)
|
||||
|
||||
echo "-- pi (coding agent) version --"
|
||||
if ACTUAL_VERSION=$(pi --version 2>&1 | head -1); then
|
||||
if [ -n "$EXPECTED_VERSION" ]; then
|
||||
if [ "$ACTUAL_VERSION" = "$EXPECTED_VERSION" ]; then
|
||||
pass "pi version $ACTUAL_VERSION"
|
||||
if [ "$(strip_v "$EXPECTED_VERSION")" = "$(strip_v "$ACTUAL_VERSION")" ]; then
|
||||
pass "pi version $ACTUAL_VERSION (matches --expected-version)"
|
||||
elif [ -n "$MANIFEST_RELEASE_TAG" ] &&
|
||||
[ "$(strip_v "$EXPECTED_VERSION")" = "$(strip_v "$MANIFEST_RELEASE_TAG")" ]; then
|
||||
# Exact, not heuristic: the value handed over IS this image's release
|
||||
# tag, so it cannot be a pi version anyone meant.
|
||||
fail "--expected-version $EXPECTED_VERSION is the pi-devbox IMAGE version, not the pi version — use --expected-image-version $EXPECTED_VERSION (live pi is $ACTUAL_VERSION)"
|
||||
else
|
||||
fail "pi version mismatch: expected $EXPECTED_VERSION, got $ACTUAL_VERSION"
|
||||
fail "pi version mismatch: expected $EXPECTED_VERSION, got $ACTUAL_VERSION (this flag asserts the pi coding agent version; for the image release tag use --expected-image-version)"
|
||||
fi
|
||||
elif [ -n "$MANIFEST_PI_VERSION" ]; then
|
||||
# Not a tautology: the manifest records what pi reported at BUILD time,
|
||||
# while `pi --version` resolves through PATH, which a stale npm-global
|
||||
# volume install can shadow.
|
||||
if [ "$MANIFEST_PI_VERSION" = "$ACTUAL_VERSION" ]; then
|
||||
pass "pi version $ACTUAL_VERSION (matches this image's build manifest)"
|
||||
else
|
||||
fail "live pi $ACTUAL_VERSION != $MANIFEST_PI_VERSION recorded in $MANIFEST — a stale pi in the ~/.pi/npm-global volume is shadowing the baked one"
|
||||
fi
|
||||
else
|
||||
warn "pi version $ACTUAL_VERSION (no --expected-version given; pi is built from 'latest', cannot self-derive — informational only)"
|
||||
warn "pi version $ACTUAL_VERSION (no --expected-version and no build manifest to compare against — informational only)"
|
||||
fi
|
||||
else
|
||||
fail "pi --version failed"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- pi-devbox image version --"
|
||||
if [ -z "$MANIFEST_RELEASE_TAG" ]; then
|
||||
if [ -n "$EXPECTED_IMAGE_VERSION" ]; then
|
||||
fail "cannot verify --expected-image-version $EXPECTED_IMAGE_VERSION: no readable release_tag in $MANIFEST (image built before the manifest existed, or jq missing)"
|
||||
else
|
||||
warn "image release tag unknown (no readable $MANIFEST) — pi-devbox-version would say the same"
|
||||
fi
|
||||
elif [ -n "$EXPECTED_IMAGE_VERSION" ]; then
|
||||
if [ "$(strip_v "$EXPECTED_IMAGE_VERSION")" = "$(strip_v "$MANIFEST_RELEASE_TAG")" ]; then
|
||||
pass "image version $MANIFEST_RELEASE_TAG (matches --expected-image-version)"
|
||||
elif [ -n "$MANIFEST_PI_VERSION" ] &&
|
||||
[ "$(strip_v "$EXPECTED_IMAGE_VERSION")" = "$MANIFEST_PI_VERSION" ]; then
|
||||
fail "--expected-image-version $EXPECTED_IMAGE_VERSION is the pi version, not the image release tag — use --expected-version $EXPECTED_IMAGE_VERSION (this image is $MANIFEST_RELEASE_TAG)"
|
||||
else
|
||||
fail "image version mismatch: expected $EXPECTED_IMAGE_VERSION, got $MANIFEST_RELEASE_TAG — the recreate did not pick up the intended image"
|
||||
fi
|
||||
else
|
||||
warn "image version $MANIFEST_RELEASE_TAG (no --expected-image-version given — informational only)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- Persisted named volumes (must survive --force-recreate) --"
|
||||
|
||||
|
||||
+80
-3
@@ -7,7 +7,7 @@
|
||||
# - pi binary present and (if EXPECTED_PI_VERSION set) matches CI's resolved version
|
||||
# - mempalace core matches the audited pin (if EXPECTED_MEMPALACE_VERSION set)
|
||||
# - new v1.0.0 base additions (pandoc, graphviz, imagemagick, yq, tealdeer)
|
||||
# - typst PDF engine for pandoc (Unreleased) — `pandoc --pdf-engine=typst`
|
||||
# - typst PDF engine for pandoc (v1.4.0) — `pandoc --pdf-engine=typst`
|
||||
# - non-modal editors nano + micro (alongside nvim)
|
||||
# - terminfo for modern emulators: xterm-kitty, xterm-ghostty, wezterm,
|
||||
# alacritty, foot (kitty-terminfo + ncurses-term + compiled ghostty alias)
|
||||
@@ -463,6 +463,43 @@ run "pi-devbox-version --json round-trips the manifest byte-for-byte" '
|
||||
'
|
||||
run_expect "pi-devbox-version --quiet is a compact one-liner" \
|
||||
"pi-devbox-version --quiet | wc -l" "1"
|
||||
# ── Vendored skill snapshot provenance ─────────────────────────────────
|
||||
# The vendored mempalace skill is the one baked artefact with no /opt clone
|
||||
# behind it (private upstream — see VENDORED.md), so until now the manifest
|
||||
# could not say which skillset commit it came from. Two fields now travel with
|
||||
# it: the CLAIMED ref (ARG default in Dockerfile.variant) and the MEASURED
|
||||
# sha256 of the shipped bytes. Assert both are well-formed, and — separately —
|
||||
# that the measurement still describes the file in the image.
|
||||
#
|
||||
# Kept as two assertions for the same reason the component checks are: one
|
||||
# proves the fields are not empty/garbage, the other proves they are not merely
|
||||
# self-consistent. A single combined check could pass on a manifest whose hash
|
||||
# was computed from a file that was later overwritten (the pi-extensions skill
|
||||
# copy at Dockerfile.variant:165 does exactly that kind of overwrite, one stage
|
||||
# earlier), which is the failure this second one exists to catch.
|
||||
run "manifest records the vendored skill snapshot provenance" '
|
||||
j=/etc/pi-devbox/build-manifest.json
|
||||
r=$(jq -r ".skillset_snapshot_ref // empty" $j)
|
||||
s=$(jq -r ".skillset_snapshot_tree_sha256 // empty" $j)
|
||||
echo "ref=[$r] tree_sha256=[$s]" >&2
|
||||
printf "%s" "$r" | grep -qxE "[0-9a-f]{40}" \
|
||||
|| { echo "skillset_snapshot_ref is not a 40-hex commit" >&2; exit 1; }
|
||||
printf "%s" "$s" | grep -qxE "[0-9a-f]{64}" \
|
||||
|| { echo "skillset_snapshot_tree_sha256 is not a 64-hex digest" >&2; exit 1; }
|
||||
'
|
||||
# Recomputes over the whole DIRECTORY with the same tree_sha256() pipeline
|
||||
# Dockerfile.variant used to measure it, not a plain `sha256sum SKILL.md` —
|
||||
# a file-only compare here would pass even if the manifest recorded a
|
||||
# fingerprint over a directory that has since grown a second file (this is
|
||||
# not hypothetical: pi-extensions already ships two files for its skill).
|
||||
run "manifest skill fingerprint matches the baked snapshot" '
|
||||
j=/etc/pi-devbox/build-manifest.json
|
||||
d=/usr/local/share/pi-devbox/skills/mempalace
|
||||
m=$(jq -r ".skillset_snapshot_tree_sha256 // empty" $j)
|
||||
a=$( (cd "$d" && find . -type f -print | LC_ALL=C sort | xargs -r sha256sum) | sha256sum | cut -d" " -f1)
|
||||
echo "manifest=[$m] actual=[$a]" >&2
|
||||
[ -n "$m" ] && [ "$m" = "$a" ]
|
||||
'
|
||||
# OCI labels live in the image config, not the container fs — inspect them
|
||||
# from the host docker rather than via `docker run`.
|
||||
LBL=$(docker inspect --format '{{ index .Config.Labels "se.jordbo.pi-devbox.pi-extensions-ref" }}' "$IMAGE" 2>/dev/null || true)
|
||||
@@ -539,8 +576,24 @@ exec_test "mempalace skill linked (fallback)" 'test -L $HOME/.agents/skills
|
||||
# one absent, so a re-vendored stale snapshot fails just as loudly as a
|
||||
# forgotten bump. A one-way canary only catches half the drift.
|
||||
# * a phrase canary can only ever detect "older than what I remembered to pin",
|
||||
# never "older than skillset main". The real fix is a CI job diffing this
|
||||
# file against the skillset repo — see the Unreleased changelog note.
|
||||
# never "older than skillset main".
|
||||
#
|
||||
# That structural limit is now addressed, but NOT by the "CI job diffing this
|
||||
# file against the skillset repo" this comment used to point at (that pointer
|
||||
# also dangled: it referenced an Unreleased changelog note that had become the
|
||||
# v1.8.7 heading). A CI diff cannot be done without granting CI a credential
|
||||
# for the PRIVATE skillset repo, and it would guard a file that on this fleet
|
||||
# NO host reads — all four compose stacks mount a workspace containing the
|
||||
# skillset, so devbox-skill-reconcile repoints this link at the live clone and
|
||||
# the baked copy is a CI/no-mount fallback only. Instead the snapshot now
|
||||
# carries its provenance (skillset_snapshot_ref + a measured
|
||||
# skillset_snapshot_sha256 in build-manifest.json, written by
|
||||
# scripts/vendor-mempalace-skill.sh), which moves the check to where the
|
||||
# skillset actually IS: `scripts/vendor-mempalace-skill.sh --check` for a
|
||||
# maintainer, and `pi-devbox-version` for an agent inside any container.
|
||||
# This assertion is kept because it is orthogonal and free: it pins content,
|
||||
# not provenance, so it still catches a re-vendored snapshot whose ref was
|
||||
# bumped correctly but whose bytes came from the wrong place.
|
||||
exec_test "mempalace skill snapshot is current" 'f=$HOME/.agents/skills/mempalace/SKILL.md; grep -q "Provenance is stamped for you" "$f" && ! grep -q "Attribute what you file yourself" "$f" && echo ok'
|
||||
# Link TARGETS, not just link existence: with no skillset mounted (as here) the
|
||||
# baked tree must be what resolves, for all three vendored skills.
|
||||
@@ -551,6 +604,30 @@ exec_test "vendored skills resolve to the baked tree (no skillset mounted)" \
|
||||
*) echo "$s resolves to $(readlink -f $HOME/.agents/skills/$s)" >&2; exit 1 ;;
|
||||
esac
|
||||
done; echo ok'
|
||||
# ... and that the tool REPORTS that resolution, which is the half that was
|
||||
# missing: a stale baked snapshot and a current live clone were
|
||||
# indistinguishable from inside the container. CI mounts no skillset, so every
|
||||
# vendored skill must report "baked" here — which also makes this a real test of
|
||||
# the fallback path rather than of the environment it happens to run in.
|
||||
exec_test "pi-devbox-version reports skill sources (all baked, no skillset here)" \
|
||||
'out=$(pi-devbox-version)
|
||||
echo "$out" | grep -q "skills:" || { echo "no skills section" >&2; exit 1; }
|
||||
for s in mempalace pi-extensions pi-devbox-environment; do
|
||||
echo "$out" | grep -qE "^ $s +baked$" \
|
||||
|| { echo "$s not reported as baked" >&2; exit 1; }
|
||||
done; echo ok'
|
||||
# The boot banner must NOT carry the section: entrypoint-user.sh prints the
|
||||
# version FIRST, before the baked links exist and long before the skillset
|
||||
# deploy + reconcile run last, so anything it said about skill sources would be
|
||||
# a pre-reconcile state that is about to change.
|
||||
# A bare negative (`! grep -q "skills:"`) passes if the tool crashes or
|
||||
# prints nothing at all — it cannot tell "correctly omitted the section"
|
||||
# apart from "the binary is broken". Anchor it positively: the command must
|
||||
# still succeed and still print its normal release-tag line.
|
||||
exec_test "pi-devbox-version --no-skills omits the skills section" \
|
||||
'out=$(pi-devbox-version --no-skills) && echo "$out" | grep -q "^pi-devbox " && ! echo "$out" | grep -q "skills:"'
|
||||
exec_test "entrypoint prints the version banner with --no-skills" \
|
||||
'grep -q "pi-devbox-version --no-skills" /usr/local/bin/entrypoint-user.sh'
|
||||
# The handover path itself. CI never mounts a skillset, so without this the
|
||||
# v1.8.5 fix would ship untested: fabricate a skillset + a skills dir holding
|
||||
# baked-style links, run the reconciler, and assert all three outcomes —
|
||||
|
||||
Executable
+293
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env bash
|
||||
# vendor-mempalace-skill.sh — refresh the vendored mempalace skill snapshot
|
||||
# AND its recorded provenance, together, so the two cannot drift apart.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# ---------------
|
||||
# rootfs/usr/local/share/pi-devbox/skills/mempalace/SKILL.md is a snapshot of a
|
||||
# file owned by the PRIVATE skillset repo (see VENDORED.md). Because the image
|
||||
# cannot clone that repo, refreshing the snapshot was a manual `cp` — and the
|
||||
# result was anonymous: nothing recorded WHICH skillset commit the bytes came
|
||||
# from. The only staleness check available was a hand-maintained phrase canary
|
||||
# in scripts/smoke-test.sh, which by construction detects "older than the phrase
|
||||
# I remembered to pin", never "older than skillset main".
|
||||
#
|
||||
# Two facts now travel with the snapshot: the skillset commit it was taken from
|
||||
# (ARG SKILLSET_SNAPSHOT_REF in Dockerfile.variant) and the sha256 of the bytes
|
||||
# themselves (measured at build time into build-manifest.json). This script is
|
||||
# the only thing that should ever write the first one, because a `cp` without a
|
||||
# matching ARG bump produces a manifest that CONFIDENTLY LIES — worse than the
|
||||
# anonymous snapshot it replaced.
|
||||
#
|
||||
# HARDENED after peer review (pi@emb-7kj4vr4g, logstream correlation
|
||||
# skills-provenance-review, 2026-08-26) proved the original --check could print
|
||||
# OK and exit 0 without actually verifying anything: `git show <ref>:<path>`
|
||||
# emits NOTHING when the ref/path doesn't resolve, and `sha256sum` still hashes
|
||||
# that empty stdin, so "ref not found" silently collided with "the file really
|
||||
# is 0 bytes". Depending on which side of the comparison hit the collision this
|
||||
# fell through as either a false MISMATCH (blaming provenance for what was
|
||||
# really an incomplete clone) or, worse, a false OK. See EXIT STATUS below —
|
||||
# "cannot determine" is now its own outcome, distinct from "confirmed wrong",
|
||||
# which is the same distinction the phrase canary this script replaced lacked.
|
||||
#
|
||||
# USAGE
|
||||
# scripts/vendor-mempalace-skill.sh [skillset-root] [--force]
|
||||
# refresh: rewrite the snapshot and the ARG together.
|
||||
# scripts/vendor-mempalace-skill.sh --check [skillset-root]
|
||||
# verify only, writes nothing. The root path and any flag may appear in
|
||||
# either order — a positional-only parser previously made `<root>
|
||||
# --check` silently run a refresh instead of the verification asked for.
|
||||
#
|
||||
# skillset-root defaults to /workspace/skillset, then $HOME/skillset.
|
||||
#
|
||||
# --force (refresh mode only) proceed even when the recorded ref cannot be
|
||||
# proven to be an ancestor of the skillset's current HEAD — i.e.
|
||||
# skip the guard against silently REWINDING provenance, which a
|
||||
# detached HEAD, an older checkout, or a shallow clone lacking the
|
||||
# recorded commit can all trigger. Meant to be used deliberately,
|
||||
# not habitually: each use is a human deciding a rewind is fine.
|
||||
#
|
||||
# --check answers "is the committed snapshot really skillset@<recorded ref>?"
|
||||
# — the question CI cannot answer without a credential for the private repo,
|
||||
# and which anyone with the skillset checked out can answer for free.
|
||||
#
|
||||
# EXIT STATUS (same three codes in both modes)
|
||||
# 0 the operation succeeded, or (--check) the record is verified truthful.
|
||||
# This INCLUDES a truthful record that is merely stale — upstream has
|
||||
# moved on since the recorded ref, or the local working tree has since
|
||||
# diverged. A NOTICE is printed to stderr, but the snapshot is not being
|
||||
# accused of lying, so this is not a release-blocking failure. Skipping a
|
||||
# refresh is a legitimate release-day choice (see AGENTS.md); this exit
|
||||
# code is what makes that choice checkable rather than merely asserted.
|
||||
# 1 refused: a CONFIRMED problem. Dirty upstream file; a refresh that would
|
||||
# rewind past the recorded ref; or (--check) the vendored bytes provably
|
||||
# do NOT match the file at the recorded ref — a lying record.
|
||||
# 2 cannot determine: the recorded ref, or the path at that ref, is not
|
||||
# resolvable in this clone. Commonly a shallow clone missing history, or
|
||||
# a ref that was rewritten or never pushed. Deliberately NOT the same as
|
||||
# 1 — "I can't tell" must never be reported as "it's wrong".
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
DOCKERFILE="Dockerfile.variant"
|
||||
VENDORED="rootfs/usr/local/share/pi-devbox/skills/mempalace/SKILL.md"
|
||||
ARG_NAME="SKILLSET_SNAPSHOT_REF"
|
||||
REL_PATH="skills/mempalace/SKILL.md"
|
||||
|
||||
die() { printf '%s: %s\n' "$(basename "$0")" "$1" >&2; exit 1; }
|
||||
|
||||
# Parse flags and the optional root path in either order, and reject anything
|
||||
# unrecognised rather than silently absorbing it.
|
||||
MODE="refresh"
|
||||
FORCE=0
|
||||
ROOT=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--check) MODE="check" ;;
|
||||
--force) FORCE=1 ;;
|
||||
# This is the one script whose argument ORDER was itself a landmine, so the
|
||||
# path that documents the trap must not be the path that errors.
|
||||
-h|--help)
|
||||
awk 'NR>1 && /^#/ { sub(/^# ?/, ""); print; next } NR>1 { exit }' "$0"
|
||||
exit 0
|
||||
;;
|
||||
--*) die "unknown option: $arg (try --help)" ;;
|
||||
*)
|
||||
[ -z "$ROOT" ] || die "unexpected extra argument: $arg (root already set to $ROOT)"
|
||||
ROOT="$arg"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
if [ "$MODE" = "check" ] && [ "$FORCE" = 1 ]; then
|
||||
die "--force has no effect with --check (nothing is written); remove it"
|
||||
fi
|
||||
|
||||
if [ -z "$ROOT" ]; then
|
||||
for candidate in /workspace/skillset "$HOME/skillset"; do
|
||||
if [ -d "$candidate/.git" ]; then
|
||||
ROOT="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
[ -n "$ROOT" ] || die "no skillset clone found (pass one: $(basename "$0") /path/to/skillset)"
|
||||
[ -d "$ROOT/.git" ] || die "not a git clone: $ROOT"
|
||||
[ -f "$ROOT/$REL_PATH" ] || die "no $REL_PATH in $ROOT"
|
||||
[ -f "$VENDORED" ] || die "vendored snapshot missing: $VENDORED"
|
||||
# LOAD-BEARING, DO NOT DELETE AS "REDUNDANT WITH THE EXISTENCE PROBES": -f
|
||||
# accepts an empty file, and sha256 of an empty file equals sha256 of a failed
|
||||
# pipeline's empty stdin. Guarding it HERE, before mode dispatch, makes that
|
||||
# collision unreachable by construction rather than by a probe further down --
|
||||
# which also means no test below exercises the collision any more. Remove this
|
||||
# line and the false "OK" for a nonexistent ref returns with nothing failing.
|
||||
[ -s "$VENDORED" ] || die "vendored snapshot is empty: $VENDORED"
|
||||
|
||||
head_sha=$(git -C "$ROOT" rev-parse HEAD 2>/dev/null) || die "cannot read HEAD of $ROOT"
|
||||
recorded=$(grep -oE "^ARG ${ARG_NAME}=[0-9a-f]{40}$" "$DOCKERFILE" | cut -d= -f2 || true)
|
||||
[ -n "$recorded" ] || die "no 'ARG ${ARG_NAME}=<40-hex>' line in $DOCKERFILE"
|
||||
|
||||
sha_of() { sha256sum "$1" | cut -d' ' -f1; }
|
||||
vendored_sha=$(sha_of "$VENDORED")
|
||||
upstream_sha=$(sha_of "$ROOT/$REL_PATH")
|
||||
|
||||
# Does $REL_PATH exist at HEAD at all? Proven with `cat-file -e` BEFORE
|
||||
# hashing anything. Piping a failed `git show` straight into sha256sum, as
|
||||
# this script used to, hashes an EMPTY stream and produces sha256(""): a real,
|
||||
# collidable value — not a representation of absence. That collapsed "doesn't
|
||||
# exist" and "exists and happens to be empty" into the same signal, which is
|
||||
# exactly the defect class the peer review found in --check's at_ref, below.
|
||||
blob_sha=""
|
||||
if git -C "$ROOT" cat-file -e "HEAD:$REL_PATH" 2>/dev/null; then
|
||||
blob_sha=$(git -C "$ROOT" show "HEAD:$REL_PATH" | sha256sum | cut -d' ' -f1)
|
||||
fi
|
||||
|
||||
upstream_dirty=""
|
||||
if [ -z "$blob_sha" ]; then
|
||||
upstream_dirty="not present at HEAD (untracked, or absent at this commit)"
|
||||
elif [ "$blob_sha" != "$upstream_sha" ]; then
|
||||
if ! git -C "$ROOT" diff --quiet -- "$REL_PATH" 2>/dev/null; then
|
||||
upstream_dirty="modified but not committed"
|
||||
elif ! git -C "$ROOT" diff --cached --quiet -- "$REL_PATH" 2>/dev/null; then
|
||||
upstream_dirty="staged but not committed"
|
||||
else
|
||||
upstream_dirty="different at HEAD than in the working tree"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$MODE" = "check" ]; then
|
||||
# Resolve the recorded ref the same careful way: existence is proven with
|
||||
# `cat-file -e` before anything is hashed, and "the ref itself is missing"
|
||||
# is reported distinctly from "the ref resolves but the path isn't there
|
||||
# at it" — both used to be silently swallowed into a plausible sha256("").
|
||||
ref_exists=0
|
||||
path_at_ref_exists=0
|
||||
at_ref=""
|
||||
if git -C "$ROOT" cat-file -e "${recorded}^{commit}" 2>/dev/null; then
|
||||
ref_exists=1
|
||||
if git -C "$ROOT" cat-file -e "${recorded}:${REL_PATH}" 2>/dev/null; then
|
||||
path_at_ref_exists=1
|
||||
at_ref=$(git -C "$ROOT" show "${recorded}:${REL_PATH}" | sha256sum | cut -d' ' -f1)
|
||||
fi
|
||||
fi
|
||||
|
||||
printf 'recorded ref: %s\n' "$recorded"
|
||||
printf 'vendored sha256: %s\n' "$vendored_sha"
|
||||
if [ "$path_at_ref_exists" = 1 ]; then
|
||||
printf 'sha256 at ref: %s\n' "$at_ref"
|
||||
elif [ "$ref_exists" = 1 ]; then
|
||||
printf 'sha256 at ref: <%s not present at %s>\n' "$REL_PATH" "${recorded:0:7}"
|
||||
else
|
||||
printf 'sha256 at ref: <%s not present in this clone>\n' "${recorded:0:7}"
|
||||
fi
|
||||
printf 'skillset HEAD: %s (%s)\n' "$head_sha" "$upstream_sha"
|
||||
if [ -n "$upstream_dirty" ]; then
|
||||
printf 'live working tree: %s\n' "$upstream_dirty"
|
||||
fi
|
||||
|
||||
rc=0
|
||||
if [ "$ref_exists" != 1 ]; then
|
||||
printf 'CANNOT-DETERMINE: %s is not present in %s — fetch, or check against a complete clone\n' "$recorded" "$ROOT" >&2
|
||||
rc=2
|
||||
elif [ "$path_at_ref_exists" != 1 ]; then
|
||||
printf 'MISMATCH: %s does not exist at %s in this clone — the recorded ref cannot be describing these bytes\n' "$REL_PATH" "$recorded" >&2
|
||||
rc=1
|
||||
elif [ "$at_ref" != "$vendored_sha" ]; then
|
||||
printf 'MISMATCH: the vendored snapshot is NOT the file at the recorded ref\n' >&2
|
||||
rc=1
|
||||
else
|
||||
printf 'OK: the vendored snapshot is exactly skillset@%s:%s\n' "${recorded:0:7}" "$REL_PATH"
|
||||
fi
|
||||
|
||||
# Staleness is orthogonal to truthfulness: a record can correctly describe
|
||||
# an old commit even after upstream has moved on, and a dirty local working
|
||||
# tree in $ROOT doesn't rewrite git history either — it says nothing about
|
||||
# whether the RECORDED, committed ref describes the RECORDED, committed
|
||||
# bytes. Only worth reporting once we already know rc=0 (truthful) — a
|
||||
# MISMATCH or CANNOT-DETERMINE is the dominant fact and a staleness note
|
||||
# would only muddy it.
|
||||
if [ "$rc" = 0 ] && [ "$vendored_sha" != "$upstream_sha" ]; then
|
||||
# Name the ACTUAL cause. "working tree differs" is wrong when the tree is
|
||||
# clean and the ref simply moved on — a message that names the wrong cause
|
||||
# is the same defect class as a canary pinned to a deleted phrase.
|
||||
if [ "$recorded" != "$head_sha" ] && [ "$blob_sha" = "$upstream_sha" ]; then
|
||||
# Do not ASSERT which side is newer — test it. Asserting that HEAD is the
|
||||
# newer side points the operator at a refresh (which costs a ~67-minute
|
||||
# base rebuild) when the real remedy may be `git pull` in this clone. The
|
||||
# refresh path below already uses this primitive; reuse it here.
|
||||
if git -C "$ROOT" merge-base --is-ancestor "$recorded" "$head_sha" 2>/dev/null; then
|
||||
printf 'NOTICE: %s has moved on to %s; the snapshot describes the older %s (stale, not untruthful — refresh to catch up)\n' \
|
||||
"$ROOT" "${head_sha:0:7}" "${recorded:0:7}" >&2
|
||||
elif git -C "$ROOT" merge-base --is-ancestor "$head_sha" "$recorded" 2>/dev/null; then
|
||||
printf 'NOTICE: %s is BEHIND at %s; the snapshot describes the newer %s — pull this clone, do NOT refresh the snapshot\n' \
|
||||
"$ROOT" "${head_sha:0:7}" "${recorded:0:7}" >&2
|
||||
else
|
||||
printf 'NOTICE: %s (HEAD %s) and the recorded %s have DIVERGED — neither is an ancestor of the other; reconcile the clone before refreshing\n' \
|
||||
"$ROOT" "${head_sha:0:7}" "${recorded:0:7}" >&2
|
||||
fi
|
||||
else
|
||||
printf 'NOTICE: the working tree of %s differs from the snapshot (HEAD %s)\n' \
|
||||
"$ROOT" "${head_sha:0:7}" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
exit "$rc"
|
||||
fi
|
||||
|
||||
[ -z "$upstream_dirty" ] || die "$ROOT/$REL_PATH is $upstream_dirty — commit it first, or the recorded ref would not describe these bytes"
|
||||
|
||||
if [ "$vendored_sha" = "$upstream_sha" ] && [ "$recorded" = "$head_sha" ]; then
|
||||
printf 'already current: snapshot == skillset@%s\n' "${head_sha:0:7}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Refuse to silently REWIND provenance. `git checkout <tag>`, a detached HEAD,
|
||||
# or an older checkout can all leave $ROOT's HEAD behind the already-recorded
|
||||
# ref; without this guard a refresh there would happily rewrite both the ARG
|
||||
# and the bytes backwards and report it as an ordinary update.
|
||||
if [ "$recorded" != "$head_sha" ]; then
|
||||
if git -C "$ROOT" cat-file -e "${recorded}^{commit}" 2>/dev/null; then
|
||||
if ! git -C "$ROOT" merge-base --is-ancestor "$recorded" "$head_sha" 2>/dev/null; then
|
||||
if [ "$FORCE" != 1 ]; then
|
||||
die "refusing: $ROOT's HEAD ($head_sha) is not a descendant of the recorded ref ($recorded) — this looks like a rewind. Pass --force if this is intentional."
|
||||
fi
|
||||
printf 'WARNING: --force set; %s is not an ancestor of HEAD %s — proceeding anyway\n' "${recorded:0:7}" "${head_sha:0:7}" >&2
|
||||
fi
|
||||
else
|
||||
if [ "$FORCE" != 1 ]; then
|
||||
printf 'CANNOT-DETERMINE: %s is not present in %s (shallow clone?) — fetch full history to verify this refresh moves forward, or pass --force to proceed without that guarantee\n' "$recorded" "$ROOT" >&2
|
||||
exit 2
|
||||
fi
|
||||
printf 'WARNING: --force set; %s could not be resolved in %s — proceeding without verifying forward motion\n' "${recorded:0:7}" "$ROOT" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# Written FROM THE REF, not copied from the working tree, so the pair cannot
|
||||
# be a lie by construction. Via a temp file so a failed write cannot leave a
|
||||
# half-vendored snapshot behind.
|
||||
snap_tmp=$(mktemp)
|
||||
if ! git -C "$ROOT" show "HEAD:$REL_PATH" > "$snap_tmp" 2>/dev/null; then
|
||||
rm -f -- "$snap_tmp"
|
||||
die "cannot read HEAD:$REL_PATH from $ROOT"
|
||||
fi
|
||||
chmod 0644 -- "$snap_tmp"
|
||||
mv -- "$snap_tmp" "$VENDORED"
|
||||
[ "$(sha_of "$VENDORED")" = "$blob_sha" ] \
|
||||
|| die "internal: written snapshot does not match HEAD:$REL_PATH"
|
||||
|
||||
# In-place, and only the exact pinned line: a broad sed on this Dockerfile
|
||||
# could rewrite one of the other *_REF ARGs.
|
||||
tmp=$(mktemp)
|
||||
sed "s|^ARG ${ARG_NAME}=.*\$|ARG ${ARG_NAME}=${head_sha}|" "$DOCKERFILE" > "$tmp"
|
||||
chmod 0644 -- "$tmp"
|
||||
mv -- "$tmp" "$DOCKERFILE"
|
||||
|
||||
new_recorded=$(grep -oE "^ARG ${ARG_NAME}=[0-9a-f]{40}$" "$DOCKERFILE" | cut -d= -f2 || true)
|
||||
[ "$new_recorded" = "$head_sha" ] || die "failed to rewrite ${ARG_NAME} in $DOCKERFILE"
|
||||
|
||||
printf 'snapshot: %s -> %s\n' "${vendored_sha:0:12}" "$(sha_of "$VENDORED" | cut -c1-12)"
|
||||
printf 'ref: %s -> %s\n' "${recorded:0:7}" "${head_sha:0:7}"
|
||||
printf '\nNOTE: %s is hashed into base_tag, so this costs a base rebuild\n' "$VENDORED"
|
||||
printf 'on the next tag (~67 min). Also re-pin the phrase canary in\n'
|
||||
printf 'scripts/smoke-test.sh if the section it names changed.\n'
|
||||
Reference in New Issue
Block a user