Skip to content

Runtime-manager compatibility characterization#77

Draft
nonreagent wants to merge 20 commits into
mainfrom
runtime-manager-compat
Draft

Runtime-manager compatibility characterization#77
nonreagent wants to merge 20 commits into
mainfrom
runtime-manager-compat

Conversation

@nonreagent

@nonreagent nonreagent commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

TL;DR — philosophy & design direction

Adjacent is declarative, loud, and proud: no zero-config magic that silently does the wrong thing, no invisible state. Applied to runtime version managers, that means:

  • A pin file is a declaration. Honor it or refuse to boot. If an app declares a runtime (.tool-versions / .mise.toml / .ruby-version / .python-version / .nvmrc) and adj can't boot it with that exact version, it fails closed — same loud path as a crash — rather than quietly serving the wrong runtime. No pin = no opinion.
  • The supported way to pin a runtime is the exec-wrapped cmdmise run <task> / mise exec -- <cmd> / uv run. Verified to resolve the pin even under a bare launchd PATH. The fail-closed error names this remedy.
  • Never boot via a login shell, and no env-var overrides. Prior art: Puma-dev ran apps under a login bash shell — if your home shell was zsh, the env never activated, and there was no clean override (env vars only added invisible state). Any future opt-out lives in adjacent.toml, committed and visible.

Full rationale: docs/adr/0001-runtime-manager-resolution.md (Accepted).

This PR is the characterization that produced that decision — it does not change adj behavior yet. The fail-closed check + version probe is scoped follow-up work.


What this is

An empirical CI harness that characterizes how adj resolves language-runtime version managers (rbenv, asdf, mise, uv, nvm) when it boots an app, across the two launch contexts a real user lands in: an inherited-shell PATH vs a bare launchd-minimal PATH.

Design: docs/superpowers/specs/2026-06-28-runtime-manager-compatibility-design.md
Plan: docs/superpowers/plans/2026-06-28-runtime-manager-compatibility.md

Why the failure is dangerous (the thing the decision targets)

adj boots apps with sh -c <cmd> inheriting the daemon's env. So:

  • The interactive shell (bash/zsh/fish) never runs — it's moot. What matters is the env the daemon inherited.
  • Shim managers resolve only if their shims are on that PATH; otherwise they silently fall back to the system toolchain (wrong version, app still boots). Activation-hook managers (mise activate, nvm) never fire under sh -c.
  • A long-lived daemon inherits one environment, but modern managers resolve per-directory — so one launch context can't carry correct resolution for N apps. Architectural mismatch, not a patchable bug.

The harness proves resolution rather than mere liveness: each fixture pins a version that differs from the runner's system default and its app echoes its own resolved version, so a silent fallback mismatches the pin and fails loudly. A boot failure hard-fails (never a false green).

Observed matrix (CI-green)

Manager / mode shell launchd
rbenv 3.3.6 -> resolved 3.2.3 (system) -> fallback
asdf (node) 18.20.5 -> resolved none -> unbootable
mise (shim) 3.11.9 -> resolved 3.12.3 (system) -> fallback
mise (mise activate) — Berkopec 3.2.3 (system) -> fallback 3.2.3 -> fallback
mise exec 18.20.5 -> resolved 18.20.5 -> resolved
mise run — Berkopec remedy 3.3.6 -> resolved 3.3.6 -> resolved
uv (uv run) 3.11.9 -> resolved 3.11.9 -> resolved
nvm 22.23.1 (default) -> fallback none -> unbootable
  • mise exec / mise run / uv run resolve the pin even under a bare launchd PATH — the workaround to recommend.
  • mise activate and nvm never resolve per-directory under sh -c.
  • Node under launchd is unbootable (no /usr/bin/node); Ruby/Python fall back to system /usr/bin/{ruby,python}. That asymmetry is recorded as a distinct unbootable expectation (a missing version satisfies it; a present one violates it — so it can't false-green).

Contents

  • ci/runtime-compat/ — three version-echo servers, lib.sh + run-cell.sh (boots a real daemon per context, asserts observed-vs-pin), 8 fixtures, README.
  • .github/workflows/runtime-compat.yml — ubuntu matrix over 8 cells x 2 contexts + aggregate report + one macos-14 real-launchd smoke job.
  • docs/adr/0001-runtime-manager-resolution.md — the decision.

Follow-ups (own specs, not in this PR)

  • The fail-closed check: where the conflict surfaces (status DTO field, 502-with-reason, app log — likely all three).
  • The pin-file -> resolved-version probe mechanism.
  • Product question: whether adj should help with the Node-under-launchd gap beyond documenting it.

🤖 Generated with Claude Code

nonreagent and others added 17 commits June 28, 2026 21:57
Empirically characterize how adj resolves language-runtime version
managers (mise, asdf, rbenv, uv, nvm) across launch contexts, using a
non-default-pin success signal so silent system-toolchain fallback fails
loudly. Includes the Berkopec-profile fixture (fish + mise activate) as a
real-world instance of the activation-camp failure case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Shell-driven harness (ci/runtime-compat) plus a GitHub Actions matrix that
runs 8 manager/mode fixtures across inherited-shell and launchd-minimal
contexts, with a mise-shim tracer that is locally verifiable before the rest
fan out. Includes the Berkopec failure/remedy fixtures and a macOS launchd
smoke job.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Boot failures now hard-fail instead of satisfying a fallback expectation; the
macOS smoke job exports ADJACENT_HOME so the client reaches the daemon socket;
setup.sh runs in-pipeline so install failures abort the job.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Without this, run-cell.sh always exited 143 (the SIGTERMed daemon's wait
status leaking through the EXIT trap), failing every CI cell regardless of
the pass/fail RESULT.

The root cause had two layers: (1) capturing $? into a local var is correct,
but (2) with set -e active in the EXIT trap, `wait` returning 143 aborted the
function before `return "$status"` could execute. Adding `|| true` after
`wait` lets the function reach its return statement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@nonreagent nonreagent requested a review from nonrational June 28, 2026 22:11
@nonreagent

Copy link
Copy Markdown
Collaborator Author

Self-review (agent)

Reviewed the full diff before handing off. Found and fixed four real bugs — recording them here for your eyes:

  1. False green on boot failure (run-cell.sh) — a failed boot made curl fail → empty observed version → [ "" != "$pin" ] true → fallback cells reported pass. "Never booted" was masquerading as "fell back to system runtime," which would have quietly defeated the whole characterization. Now a missing version hard-fails regardless of expectation. Regression-checked with a forced cmd = "false" fixture (now status=fail, exit 1).

  2. Exit code always 143 (lib.sh stop_daemon) — the EXIT trap's wait on the SIGTERMed daemon (143) leaked through as the script's exit code, so run-cell.sh exited 143 on both pass and fail. Under the workflow's set -o pipefail that would have failed every cell — including passing ones — and destroyed the pass/fail signal. Fixed by preserving $? across the trap (plus || true on wait, since set -e was aborting the trap before the restore).

  3. macOS smoke job hit the wrong socket — the job shell never exported ADJACENT_HOME=$home, so adj add talked to ~/.adjacent/sock while the launchd daemon listened on $home/sock. Now exported.

  4. Masked setup.sh failures — installs ran inside eval "$(...)", swallowing failures. Now run in-pipeline so pipefail aborts loudly.

Verified locally

  • mise-shim-python tracer: shell → 3.11.9 (resolved), launchd → system 3.12.3 (fallback). Both pass, correct exit codes.

Known follow-up (by design)

The mise-exec / mise-run / uv cells are record-only under launchd until the first green CI run tells us whether the self-resolving-binary workaround survives a bare PATH. Once CI reports, those EXPECT_LAUNCHD values get pinned to observed truth and any divergence from the design's expectations becomes a fix-vs-document call.

Note for CI: mise's python-build-standalone attestation check fails on clean runners, so the mise-shim fixture sets MISE_PYTHON_GITHUB_ATTESTATIONS=false. If the node/ruby mise cells hit a similar attestation wall, they'll need the equivalent — watching the first run for that.

mise-exec/mise-run/uv resolve their pin under launchd (workaround survives a
bare PATH). asdf/nvm Node apps are unbootable under launchd — no /usr/bin/node
to fall back to, unlike Ruby which falls back to /usr/bin/ruby. Adds an
`unbootable` expectation (missing version satisfies it, present version
violates it) so the finding is asserted rather than a false green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@nonreagent

Copy link
Copy Markdown
Collaborator Author

CI green — characterization complete

Second run is fully green (all 8 cells + macOS real-launchd smoke + aggregate report). Observed matrix:

Manager / mode shell launchd
rbenv 3.3.6 → resolved 3.2.3 (system) → fallback
asdf (node) 18.20.5 → resolved none → unbootable
mise (shim) 3.11.9 → resolved 3.12.3 (system) → fallback
mise (mise activate) — Berkopec 3.2.3 (system) → fallback 3.2.3 → fallback
mise exec 18.20.5 → resolved 18.20.5 → resolved
mise run — Berkopec remedy 3.3.6 → resolved 3.3.6 → resolved
uv (uv run) 3.11.9 → resolved 3.11.9 → resolved
nvm 22.23.1 (default) → fallback none → unbootable

Takeaways (full write-up in the spec's "Observed results" section):

  • mise exec / mise run / uv run resolve the pinned runtime even under a bare launchd PATH — the workaround to recommend to mise activate users.
  • mise activate and nvm never resolve per-directory under sh -c (confirmed).
  • Node under launchd is unbootable (no /usr/bin/node), while Ruby falls back to system /usr/bin/ruby. Documented as expected; whether adj should help (PATH guidance / knob) is left as a product follow-up.

Ready for your review — left as a draft for you to mark ready / merge.

nonreagent and others added 2 commits June 28, 2026 22:55
Records the decision flowing from the PR #77 characterization: surface
version-pin mismatches (detect-and-warn), document the mise exec/run / uv run
pattern as supported, and defer transparent resolution (login-shell or
per-manager adapters) as each trades away stack-agnosticism or determinism.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…l prior art

Sharpen the decision from detect-and-warn to detect-and-fail: a declared pin
that adj can't satisfy refuses the boot (the pin file is the declaration), per
the declarative/loud/proud value. Records Puma-dev's login-shell trap (bash
runner + zsh home shell = wrong env, no good override) as the prior art behind
rejecting login-shell execution and env-var overrides. Mark Accepted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant