Compare commits

..

2 Commits

Author SHA1 Message Date
joakimp aac4a1c323 release: v1.8.9 — the version flag that blamed the wrong component
Lint / hadolint (push) Successful in 15s
Lint / actionlint (push) Successful in 18s
Publish Docker Image / resolve-versions (push) Successful in 9s
Publish Docker Image / base-decide (push) Successful in 9s
Publish Docker Image / build-base (push) Successful in 41m49s
Publish Docker Image / smoke (push) Successful in 4m51s
Publish Docker Image / smoke-studio (push) Successful in 4m59s
Publish Docker Image / build-variant-studio (push) Successful in 16m58s
Publish Docker Image / build-variant (push) Successful in 28m35s
Publish Docker Image / update-description (push) Successful in 7s
Publish Docker Image / promote-base-latest (push) Successful in 17s
Two versions, two flags. `--expected-version` has only ever asserted
`pi --version`, but AGENTS.md step 4 spelled it `X.Y.Z` inside a checklist where
every other X.Y.Z is the pi-devbox tag. Run as documented for v1.8.8 the final
runtime gate of the release printed

    ✗ pi version mismatch: expected 1.8.8, got 0.84.3

and exited 1 — a red accusing the image of being the wrong version. Not one
reader's slip: the v1.8.8 release-readiness handoff from pi@emb-7kj4vr4g
propagated the same wrong spelling twice while correctly calling step 4 "not
ceremonial", so two independent readers converged on it. README.md had it right
all along, which means the two documents disagreed.

- new --expected-image-version asserts the pi-devbox release tag, read from
  release_tag in /etc/pi-devbox/build-manifest.json (no checkout, no network);
  leading `v` optional on either side
- both flags detect being handed the other one's value, and the test is exact
  rather than heuristic: the value is compared against the other quantity the
  image itself reports, so it can only fire on a real mix-up
- neither flag is required now. With none, live `pi --version` is asserted
  against the manifest's pi_version — not a tautology, since a stale pi in the
  ~/.pi/npm-global volume can shadow the baked one, exactly as a stale
  npm:pi-atelier can in packages[]
- the header note replaced was stale and load-bearing: it claimed pi is resolved
  from 'latest' and cannot be self-derived, while Dockerfile.variant pins
  ARG PI_VERSION=0.84.3 and docker-publish.yml reads that ARG as its source of
  truth. The same withdrawn claim also sat in cli_utils' pi-devbox-sanity --help
- argument parsing: a missing value, or a value that is another flag, is a usage
  error instead of silently consuming the next argument; --help works

All fourteen flag combinations exercised by execution, including the two
manifest-absent branches and the shadowing branch a healthy container cannot
reach — mutation-tested with a doctored manifest so each failure branch was
observed firing rather than assumed present.

CHANGELOG also names what no commit here causes: mempalace-toolkit main moved
e70bef2 -> 5b8d78f, so this tag ships the auto-delivered logstream mailbox
because base_tag folds the resolved toolkit SHA. It would have landed either
way; going unnamed is the 553d865 shape that already caused one cross-host
misattribution. Component audit found nothing else to bump — pi, mempalace,
pi-atelier all equal their upstream latest, and every other floating ref
resolves to the commit already baked.
2026-08-26 18:47:03 +02:00
joakimp 34cf1e3810 release: v1.8.8, and a notice that named the wrong remedy
Lint / hadolint (push) Successful in 14s
Lint / actionlint (push) Successful in 17s
Publish Docker Image / resolve-versions (push) Successful in 10s
Publish Docker Image / base-decide (push) Successful in 19s
Publish Docker Image / build-base (push) Successful in 1h3m31s
Publish Docker Image / smoke (push) Successful in 4m47s
Publish Docker Image / smoke-studio (push) Successful in 8m8s
Publish Docker Image / build-variant-studio (push) Successful in 20m4s
Publish Docker Image / build-variant (push) Successful in 26m9s
Publish Docker Image / promote-base-latest (push) Successful in 9s
Publish Docker Image / update-description (push) Successful in 14s
Freezes the v1.8.8 section and clears the two non-code checklist items
pi@emb-7kj4vr4g handed over (evt_20260826T134919_a614ecfc2d4f), plus the two
carried nits from its round-2 verification (evt_20260826T133356_d56792791a49).
Every claim below was re-measured here rather than taken from the handoff.

THE STALENESS NOTICE ASSERTED A DIRECTION IT NEVER TESTED — Blocker 1's shape,
one layer down, in the message I added to replace the message that named the
wrong cause. The notice fired on "recorded != HEAD" and then announced HEAD as
the newer side without testing ancestry, so a clone that was merely BEHIND got
"has moved to 82a8d3c; the snapshot describes the older 5fd0d5c" when 82a8d3c is
5fd0d5c's ANCESTOR. Found by EMB against the real state of its own host, not a
fabrication. The verdict was never wrong (rc 0, nothing mis-verified) but the
remedy it implies is a ~67-minute base rebuild when the actual fix is `git pull`
— the only one of the two carried nits with a price tag, which is why it went
first. Now tests ancestry with the merge-base --is-ancestor primitive the refresh
path 60 lines below already used, and reports three verdicts: stale (refresh),
clone behind (pull, do NOT refresh), diverged (reconcile). All three verified by
execution; only the first was correct before. --help no longer errors on the one
script whose argument order was itself a landmine. The `-s "$VENDORED"` guard is
now commented as load-bearing: it makes the empty-stdin collision unreachable by
construction, which also means no test below exercises it any more, so deleting
it as "redundant with the probes" would silently restore the false OK.

CHANGELOG: retitled, and three stale spots fixed in what becomes the permanent
record. It cited the skill at 82a8d3c (twice superseded); it RE-ASSERTED the
retracted mailbox measurement as live evidence 200 lines after withdrawing it,
which is the exact non-contradiction failure this release exists to fix; and its
warning block still described the pre-e8ddeaf world ("still records c04cd15",
"now exits 1") while quoting as exemplary the very notice whose direction was
unverified. Per EMB's steer the conclusion was kept and only the evidence
replaced: the status filter does drop broadcast noise, it just never computed
owed-ness. Honest replacement, measured on both machines: raw filter returns 2
here and 1 there, EVERY ONE already answered, derivation returns 0 for both.

SNAPSHOT RESYNCED AGAIN, 5fd0d5c -> 6eb20af, because skillset 6eb20af adds the
limit of my own seq ordering test: seq is REPLICA-LOCAL, equal to origin_seq only
because one replica authors for all four machines, so use hlc once mesh_peers
reports a peer. Recorded as reasoning not measurement — a second replica cannot
be stood up here. The durable half is the asymmetry: seq skew makes an ANSWERED
item resurface (noise, visible, self-correcting) while created_at SUPPRESSES AN
UNANSWERED ask forever (silent, permanent), so the skill now says outright that
"fixing" a resurfacing item with a timestamp trades the safe failure for the
dangerous one. The resync was free: rootfs/ was already changing, so the base
rebuild was forced regardless — the ordering warning about accidental staleness
does not apply to a deliberate refresh before the tag.

--check is a clean OK at 6eb20af with no notice, canary re-verified bidirectionally
(present 3, withdrawn 0), baked snapshot 0644, tree hash recomputed at build time
and re-verified in-container. bash -n clean; shellcheck/hadolint/actionlint remain
absent locally, so CI is still the only evidence for those.
2026-08-26 15:56:03 +02:00
7 changed files with 398 additions and 43 deletions
+17 -3
View File
@@ -98,9 +98,23 @@ re-brand of opencode-devbox's `pi-only` variant.
**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.
`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
+197 -19
View File
@@ -11,7 +11,148 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
---
## Unreleased
## 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.
@@ -45,6 +186,23 @@ being fixed.**
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
@@ -88,8 +246,22 @@ earlier. Fixed in skillset `5fd0d5c`, which derives owed-ness by joining on
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`) so the no-clone fallback does not ship
the withdrawn rule. Canary re-verified bidirectionally against the new bytes.
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
@@ -275,14 +447,17 @@ the skillset actually is — a maintainer's clone, or any running container.
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 (`82a8d3c`, live on every host that mounts
- 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. Measured
while designing it: an unfiltered mailbox returned 5 events, 4 of them
finished broadcasts from eight days earlier, where `status="open"` returned
exactly the 1 that needed an answer — an unfiltered mailbox trains you to
ignore it, so the filter is the feature.
`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
@@ -290,16 +465,19 @@ the skillset actually is — a maintainer's clone, or any running container.
implements `GET /logstream/stream`, but a reverse proxy exposing only `/mcp`
makes it unreachable — verified by 404s against the real endpoint.
⚠️ **This makes the vendored snapshot stale on purpose.** The skill edit is in
the skillset (`82a8d3c`), so `SKILLSET_SNAPSHOT_REF` still records `c04cd15`
and `scripts/vendor-mempalace-skill.sh --check` now exits 1 with
*"has moved to 82a8d3c; the snapshot describes the older c04cd15"*. Refreshing
it is a deliberate release-day decision, not an oversight — hence the new
step 2 in `AGENTS.md` § *Release-day checklist*, which states both that the
refresh costs a base rebuild and that skipping it is legitimate because every
enrolled host reads its live clone. What is not legitimate is skipping it
*silently*, which is precisely what the new manifest fields and
`pi-devbox-version` output make impossible.
⚠️ **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.
---
+1 -1
View File
@@ -305,7 +305,7 @@ ARG MEMPALACE_TOOLKIT_REF=main
# 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=5fd0d5c406df506fd0e70977b6bd87f5fbc3b803
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
+13 -2
View File
@@ -1017,10 +1017,21 @@ After `docker compose up -d --force-recreate`, run the **runtime** peer of
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 # auto-detects variant
./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
@@ -462,6 +462,24 @@ 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.
+125 -15
View File
@@ -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) --"
+27 -3
View File
@@ -86,7 +86,13 @@ for arg in "$@"; do
case "$arg" in
--check) MODE="check" ;;
--force) FORCE=1 ;;
--*) die "unknown option: $arg" ;;
# 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"
@@ -110,6 +116,12 @@ fi
[ -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"
@@ -200,8 +212,20 @@ if [ "$MODE" = "check" ]; then
# 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
printf 'NOTICE: %s has moved to %s; the snapshot describes the older %s (stale, not untruthful)\n' \
"$ROOT" "${head_sha:0:7}" "${recorded:0:7}" >&2
# 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