Skip to content
Merged
99 changes: 99 additions & 0 deletions docs/upgrade-live-dispatch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Upgrade verbs — live dispatch proof (server-binary on the mini)

The hermetic suite (`packages/amico-run/test/upgrade*.test.ts`) is the
CI-green-blocking evidence for #526. **This checklist is the review-blocking
live proof**: ONE real `amico upgrade server-binary` dispatch on the mini
(Aaron's launchd `co.harmoniqs.amicode-server`, port 4096), the appended JSONL
receipt cited in the PR body. Separability per the spec: the PR cannot merge
without the live receipt, but CI does not wait on it.

**The implementer of #526 does not run this** — the parent (walk) dispatches it
after merge, on the machine that owns the surface.

## Preconditions (verify each, one line of output)

```bash
# 1. the machine is the server (this IS the mini) and the service is alive
launchctl print gui/$(id -u)/co.harmoniqs.amicode-server | grep -E 'state|pid'
curl -fsS 'http://127.0.0.1:4096/session?limit=1' >/dev/null && echo healthy

# 2. the fork checkout is clean and on local/amicode (a dirty or diverged
# checkout aborts aborted-diverged — the human resolves it, never the verb)
git -C ~/armonia/repos/opencode status --porcelain # must print NOTHING
git -C ~/armonia/repos/opencode rev-parse --abbrev-ref HEAD # local/amicode

# 3. bun is on PATH (the build step needs it) and the amico bundle is current
bun --version
cd ~/armonia/repos/amicode && git pull --ff-only && pnpm --filter @amicode/amico-run build
```

## The dispatch

```bash
cd ~/armonia/repos/amicode
node packages/amico-run/dist/amico.js doctor # sanity: server-binary reads STALE (the reason to upgrade)
node packages/amico-run/dist/amico.js upgrade server-binary
```

Defaults, no stubs: `bun install` + `bun run build --single` in
`~/armonia/repos/opencode/packages/opencode`, artifact at
`packages/opencode/dist/opencode-<platform>/bin/opencode`, freeze into
`~/.amico/server/bin/opencode` (+ sidecar, `opencode.prev` preserved), kick via
`launchctl kickstart -k gui/$(id -u)/co.harmoniqs.amicode-server`, then poll
`GET /session?limit=1` **and** the running process's binary sha vs the sidecar
(120 s, one re-kick retry). Use `--ref <rev>` only to pin a specific rev.

The verb prints its step trace to stderr and the receipt (single-line JSON) to
stdout; expect several minutes for `bun install` + the single-binary compile.

## The receipt the PR cites

```bash
tail -1 ~/.amico/server/upgrade-receipts/upgrade-receipts.jsonl | python3 -m json.tool
```

The proof passes iff that receipt shows:

- `"verb": "server-binary"`, `"outcome": "upgraded"`, `"verification": true`
- `pre[0].verdict == "stale"` and `post[0].verdict == "current"`
- `source_digests.artifact_sha256 == source_digests.frozen_sha256`, and
`fork_head_after == fork_head_at_ref` (the clean-but-behind ff, if any, ran)
- `detail` walks the chain: smoke → freeze (`preserved current binary as
opencode.prev`) → kick → verify (`verified: healthy + running sha == sidecar`)
→ `deleted opencode.prev`

Independent cross-checks (never trust the receipt's own flag):

```bash
node packages/amico-run/dist/amico.js doctor # server-binary must now read current
shasum -a 256 ~/.amico/server/bin/opencode # == the sidecar AND receipt frozen_sha256
ls ~/.amico/server/bin/ # opencode + opencode.sha256, NO opencode.prev
```

Idempotence spot-check (optional but cheap): re-run the verb — it must exit 0
with a `no-op` receipt and touch nothing.

## If it goes wrong

The verb restores automatically: a `restored` receipt means the previous binary
is back, healthy, sidecar rewritten — surface honestly `stale` again. A
`restore-failed` receipt means the server is DOWN: `opencode.prev` is retained
(deliberately — the only good copy); escalate by hand
(`launchctl print gui/$(id -u)/co.harmoniqs.amicode-server`, the server log,
then the amico-server.sh header runbook). The receipt records why; the
watchdog/morning-brief is the failure-delivery path, never silence.

## Known live caveats (stated, not blocking)

- **staged-skills reads stale forever on the mini while internal-only skills
(fleet, develop, implement-issue, …) remain staged**: doctor #525's
extras-are-drift predicate vs `stage-internal-skills.sh`'s deliberate
no-delete staging disagree. The `skills` verb preserves internal skills
(per-skill exact re-stage of the VSIX set, set-level no-delete) and reports
the residual drift honestly in its post record — it never deletes fleet
skills. Resolving the predicate/design conflict is a doctor-slice decision,
not the verb's.
- Single-operator lock: every invocation path (SSH, panel) runs as the launchd
user; a second concurrent verb exits `aborted-locked`.
- Receipts land in `~/.amico/server/upgrade-receipts/upgrade-receipts.jsonl`
(append-only; `--root-receipts` redirects for fixtures).
16 changes: 16 additions & 0 deletions packages/amico-run/src/amico.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ function usage(): string {
["sandbox <workspace-dir> --packages A,B,…", "generate a per-problem Julia env (amico-run subcommand)"],
["estimate <script.jl> | --spec <s.json>", "v0 size estimate → JSON suggestion signal, never a route (Δ10 #34)"],
["doctor [--json] [--root-…]", "studio binding + fleet surface inventory — six records, verdicts (#402, #525)"],
[
"upgrade <server-binary|extension|agents|skills> [--root-…]",
"receipt-emitting idempotent upgrade runbooks — pre-flight gate, lock, JSONL receipts (#526)",
],
[
"pasqal devices | submit --device <d> --artifact <p> [--confirm <h>]",
"Pasqal device path — list/select + gated submit (#160)",
Expand Down Expand Up @@ -89,6 +93,18 @@ export async function main(argv: string[]): Promise<number> {
return report.exit;
}

// ── the upgrade verbs (#526, spec D2): the four upgrade chains as
// receipt-emitting runbooks. Pre-flight composes the SAME doctor v2
// probes (current → no-op; unknown → abort; stale/integrity → proceed);
// single-operator lock; append-only JSONL receipts; the server-binary
// restore path. Same {json, code} shape as the spine verbs. ──
case "upgrade": {
const { upgradeVerb } = await import("./upgrade.js");
const { json, code } = await upgradeVerb(rest);
console.log(JSON.stringify(json));
return code;
}

// ── the Pasqal device path (#160): device selection + gated submission,
// reading status ONLY from the connections cache and submitting through the
// amico-pasqal launcher. Same {json, code} shape as the spine verbs. ──
Expand Down
9 changes: 6 additions & 3 deletions packages/amico-run/src/surfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,9 @@ async function readFileSafe(p: string): Promise<string | null> {
}
}

async function fileSha(p: string): Promise<string | null> {
/** sha256 (hex) of a file's bytes, or null when unreadable. Exported for the
* upgrade verbs (#526) — they digest the same evidence doctor reads. */
export async function fileSha(p: string): Promise<string | null> {
try {
return sha256hex(await readFile(p));
} catch {
Expand Down Expand Up @@ -182,8 +184,9 @@ export function parseBuildDate(version: string): Date | null {
}

/** Deterministic content digest of a directory: sha256 over the sorted
* relative-path + file-bytes pairs. mtime-free by construction. */
async function dirDigest(dir: string): Promise<string | null> {
* relative-path + file-bytes pairs. mtime-free by construction. Exported for
* the upgrade verbs + their idempotence harness (#526). */
export async function dirDigest(dir: string): Promise<string | null> {
const files: string[] = [];
const walk = async (rel: string) => {
let entries: import("node:fs").Dirent[];
Expand Down
Loading
Loading