Skip to content

POK-387: Update active agent installations and verify outcomes - #48

Merged
saheljalal merged 3 commits into
mainfrom
pok-387-detect-active-agent-updater
Sep 11, 2026
Merged

saheljalal merged 3 commits into
mainfrom
pok-387-detect-active-agent-updater

Conversation

@saheljalal

@saheljalal saheljalal commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Closes POK-387

aikit update could update a different installation prefix, invoke the nonexistent codex update command, or misclassify Hermes checks. A successful process exit also counted as success when the executable on PATH stayed outdated.

Updates now detect the active installation from npm/Bun paths, pip interpreter/RECORD ownership, pipx/uv roots, Cargo receipts, mise, Homebrew, or pacman/AUR ownership. Standalone/npm/Bun installs prefer registered native updaters with manager fallback; other managed installations use their owner exclusively to keep package receipts consistent. pacman/AUR installations report the owning package and require a full system update workflow instead of running elevated partial upgrades.

Maintenance subprocesses clear injection and destination overrides while preserving identity/network settings. npm discovery retains configured prefixes. Ownership probes are cached for unchanged binaries for up to 60 seconds and invalidated before/after updates. Spawn failures allow manager fallback; timeouts stop. Every attempted update re-resolves and version-checks the executable. Bun uses plain bun install -g with BUN_INSTALL_GLOBAL_DIR and BUN_INSTALL_BIN pinned from the active installation, avoiding unsupported destination flags.

Updater errors, missing/regressed versions, and confirmed outdated results return CLI exit 1 and dashboard success: false, with bounded diagnostics including command, owner, active path, and other PATH copies. A successful updater with an unchanged version and inconclusive check remains unchanged, exits 0, and is displayed as inconclusive. This exit-code/API correction is versioned as aikit 2.0.0 under the repository rules.

Validation:

  • 600 tests passed, Python compilation and git diff --check passed.
  • Real subprocess fixtures cover all 12 reported agents across native, npm, Bun, and installer paths, plus ownership, environment-configured prefixes, manager-only updates, read-only Arch handoff, real spawn failure/timeout, cache invalidation, and neutral inconclusive Hermes results.
  • A strict Bun stub rejects unsupported arguments and verifies default/relocated destinations; both cases failed before the fix. Live Bun 1.3.14 upgraded Oh My Pi 18.1.15 β†’ 18.1.17 in both disposable layouts, exited 0, and retained the executable path.
  • Regression failures before their respective fixes, source evidence, reproduction commands, and platform limits are documented in docs/aikit-update-reproduction.md.

Tests ran on Linux. Independent QA verified real Arch pacman/AUR ownership, npm Codex upgrades, standalone Grok, mise, Linuxbrew Cask routing, and dashboard outcomes. Native Windows/macOS and true Omarchy hardware remain unverified. System-managed packages require the full system update workflow outside aikit; custom Cargo sources and unproved package ownership receive explicit instructions instead of a guessed migration.

Co-authored-by: multica-agent <github@multica.ai>

@saheljalal saheljalal left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review β€” POK-387 / PR #48

Verdict: Request changes β€” the root-cause analysis is right and the detection work is strong, but the new "verify or fail" policy collapses unknown into failed, which makes aikit update report a failure and exit 1 on a healthy, up-to-date Hermes. Two smaller correctness issues (native-first over package managers, -1 suppressing the manager fallback) and an npm-prefix discovery regression should be resolved too.

What's good: the reproduction doc is evidence-based and honest about its limits; tests/test_aikit_updates.py uses real disposable executables and package trees rather than mocks, and the regression-first methodology (five tests failing on the original code) is exactly right. I ran the suite on this branch β€” 575 passed, 1 skipped β€” and git diff --check is clean. The codex update correction, the ownership-evidence rules (pacman DB over "we're on Linux", /Cellar/ over /homebrew/, npm prefix from the active tree), and the 2.0.0 bump under the repo's exit-code rule are all well judged.

Blocker

  • aikit:2288 β€” execute_agent_update rewrites every non-upgraded/up_to_date outcome to failed, so unchanged and unchanged_outdated can never reach _do_update_impl; those display branches and the unchanged_outdated/unchanged terms in the exit-code expression are dead. An inconclusive check (available is None) on a current agent therefore reports FAILED and exits 1. Reproduced on this branch against a stub Hermes.

Should-fix

  • aikit:2293 β€” run() returns -1 for a spawn failure as well as a timeout, so break also suppresses the manager fallback when nothing ran at all.
  • aikit:1910 β€” native updater now runs before every detected manager, including brew/mise/pipx/uv/cargo/pacman, reversing the invariant the old comment recorded.
  • aikit:133 β€” _maintenance_env() in _run_npm strips NPM_CONFIG_PREFIX from the discovery call npm config get prefix, breaking _npm_global_prefixes() on env-configured prefixes.
  • aikit:1813 β€” sudo -n pacman -S --needed --noconfirm silently escalates and performs a partial upgrade on the reported platform.
  • aikit:1788 β€” _manager_installation now spawns subprocesses and is uncached; /api/agents calls it once per agent per request.

Nit

  • aikit:5928 β€” the [:500] bound on the dashboard's stderr was dropped.

Details and suggested fixes are in the inline comments.

Comment thread aikit Outdated
manager=plan["manager"], path_before=str(before), path_after=str(after), attempts=list(attempts))
if outcome["status"] in ("upgraded", "up_to_date"):
return outcome, "\n".join(outputs), "\n".join(errors)
outcome["status"] = "failed"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker β€” an inconclusive update check turns a healthy install into a hard failure.

Every outcome that is not upgraded/up_to_date is rewritten to failed here, so the unchanged and unchanged_outdated statuses that classify_update_outcome produces can never reach _do_update_impl. That makes its elif status == "unchanged_outdated" / elif status == "unchanged" branches and both summary lines dead code, and reduces return 1 if failed or unchanged_outdated or unchanged else 0 to 1 if failed.

The user-visible consequence: _text_update_availability now returns None for any phrasing it does not recognise, should_skip_update does not skip on None, the updater runs, the version legitimately does not change β€” and aikit reports FAILED and exits 1 for an agent that is perfectly current. Reproduced against this branch with a stub Hermes that is up to date but words its check differently:

❌ ☀ Hermes Agent update failed: Updater completed without a verified update; command: …/bin/hermes update; manager: native/installer; active executable: …/bin/hermes; version: 2.24.0; updater output: Nothing to do.
❌ Failed (1): Hermes Agent
do_update(...) == 1

Hermes is the agent from the original report, and its version_check is cmd_text with available_substr: "Update available". The negative regex only recognises already up to date / no updates available / you are up to date; anything else ("you're on the latest release", "latest version already installed") becomes a permanent false failure on every run, and breaks any script that checks aikit's exit status β€” which is the contract this PR just made a major version out of.

Suggested fix: preserve the classification from the last attempt and only force failed when something actually failed β€” exit_code != 0, a version regression, a missing version, or check.get("available") is True. Leave unchanged as unchanged (it already has a sensible presentation and its own exit-code slot), and widen the negative-verdict patterns in _text_update_availability while you're there. If "unknown" really should be non-zero, it should still not print and summarise as a failure.

Comment thread aikit
outcome["error"] = _update_failure_detail(agent_key, command, before, after, outcome, stderr or stdout, plan["manager"])
# A timeout may have interrupted a partial mutation. Do not start a
# second updater after an uncertain termination.
if exit_code == -1:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix β€” -1 also means "could not launch", so this suppresses the manager fallback when nothing ran.

run() returns -1 from two places: the timeout path and except Exception: return -1, "", str(e) around Popen. Confirmed on this branch:

run(["/nonexistent/bin/hermes", "update"], shell=False, timeout=5)[0]  # -> -1

The native command is bound to an absolute path by _bind_agent_command, so a shim that a previous self-update removed/replaced, or one that is not executable, fails to spawn, returns -1, and this break skips the detected-manager retry β€” exactly the recovery case this PR is built for. The comment's rationale (a timeout may have left a partial mutation) only applies to the timeout.

Suggested fix: distinguish the two β€” either a separate sentinel/flag from run() for spawn failure, or gate the break on the timeout marker it already writes into stderr (Command timed out after …). Note test_timeout_does_not_start_fallback_manager stubs run to return -1 directly, so it passes for both cases and won't catch this.

Comment thread aikit Outdated
primary = agent.get("bin", "")
native = registry if registry and (registry == primary or registry.startswith(primary + " ")) else None
commands = []
if native:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix β€” native-first now applies to package-manager-owned installs too, reversing a deliberate invariant.

native is always attempted before managed_cmd, whatever the owner is. The code this replaces did the opposite on purpose: "The detected command takes precedence over the registry's update_cmd so agents installed via mise/brew/pipx/uv are upgraded by that manager instead of self-updating out from under it" β€” and test_aikit_detect_install_manager_mise was rewritten in this PR to assert the new order.

For npm/bun/installer-owned binaries native-first is right and is what fixes the reported Hermes/Grok bug. For brew/mise/pipx/uv/cargo/pacman-owned binaries a successful native self-update writes over manager-managed files and leaves the manager's version state stale, so a later brew upgrade / mise upgrade reinstalls the old build on top. For pacman-owned binaries under /usr/bin it is usually just a wasted attempt, but a self-updater that falls back to ~/.local/bin will quietly create the second copy this PR is otherwise careful to avoid.

Suggested fix: order by owner β€” native first when the manager is npm/bun/None, manager first (with native as the fallback) for mise/brew/pipx/uv/cargo/pacman/aur. That keeps the fix for the reported agents without reversing the invariant.

Comment thread aikit Outdated
cache, env = _npm_subprocess_env()
try:
return run(cmd, timeout=timeout, env=env)
return run(cmd, timeout=timeout, env={**_maintenance_env(), **env})

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix β€” sanitising _run_npm breaks npm prefix discovery, not just mutation.

_maintenance_env() strips NPM_CONFIG_PREFIX (and the lowercase npm_config_prefix) β€” verified on this branch, both keys come back mapped to None. But _run_npm is also the discovery path: _npm_global_prefixes() (aikit:1455) calls npm config get prefix through it. On a machine that sets the global prefix via the environment, that call now reports npm's built-in default instead of the user's actual prefix, so _npm_global_prefixes() no longer knows about the real global tree.

Knock-on effects outside the update path:

  • resolve_agent_bin (aikit:1554, via _npm_global_bin_dirs) can no longer find an agent installed under that prefix when its bin dir isn't on PATH β€” which weakens exactly the detection this PR is about.
  • npm_uninstall_cmd (aikit:1486) finds no matching prefix and falls back to --prefix ~/.local, so aikit uninstall reports success while removing nothing.

The update path doesn't need the strip: _node_installation already pins --prefix/--global-dir from the resolved binary. Suggested fix: apply _maintenance_env() only to the mutating npm invocations, or have _run_npm re-add the prefix variables.

Comment thread aikit Outdated
if not helper:
return "aur", None, {}, f"AUR package {package} owns {raw}; install paru/yay or update it with your AUR build workflow"
return "aur", [helper, "-S", "--needed", "--noconfirm", package], {}, ""
prefix = ["sudo", "-n"] if hasattr(os, "geteuid") and os.geteuid() != 0 else []

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix β€” this escalates privileges and performs a partial upgrade, neither of which aikit update advertises.

sudo -n pacman -S --needed --noconfirm <pkg> mutates system packages without asking. Beyond the elevation itself, pacman -S <single-package> against the local sync database is a partial upgrade β€” the case Arch explicitly warns against, since it can pull newer dependencies into an otherwise un-upgraded system. The aur branch below has the same shape plus a source build (paru/yay -S --needed --noconfirm).

Omarchy is the platform in the report, so this is the path most likely to be hit in practice. The docs already say aikit "does not refresh system databases or perform a whole-system upgrade" β€” that reads as a safety property, but installing a single package is the unsafe half of it.

Suggested fix: for pacman/aur owners, report the owning package and direct the user to sudo pacman -Syu (consistent with the "repository metadata may lag" hint already in _update_failure_detail), or keep the automated path but put it behind an explicit opt-in flag / confirmation rather than --noconfirm under sudo -n.

Comment thread aikit
return None, None


def _manager_installation(agent_key):

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix (performance) β€” this is now subprocess-backed and uncached, but it's called per agent per dashboard request.

Detection used to be pure path-string inspection with zero subprocesses. It now runs pacman -Qoq (up to twice), possibly pacman -Qm, and _python_installation's interpreter probe β€” each with a 10s timeout β€” and there's no memoisation.

/api/agents (aikit:5827) calls resolve_update_cmd(key) for every agent on every request, and /api/agents/<key> (aikit:5860) does it again. On an Arch box with a dozen agents installed, one dashboard load is now ~2 pacman invocations per installed agent, serially, before the page renders. _update_plan also calls _installed_bin_path three more times on top of the one _manager_installation already did.

Suggested fix: memoise per agent key for the process lifetime (same pattern as UPDATE_CHECK_CACHE), and/or drop update_cmd from the list endpoint and resolve it only in the detail endpoint.

Comment thread aikit Outdated
"latest_version": outcome.get("latest") if outcome else None,
"stdout": stdout[:500] if stdout else "",
"stderr": (outcome.get("error") or stderr)[:500] if (outcome or stderr) else "",
"stderr": ((outcome or {}).get("error") or stderr) if (outcome or stderr) else "",

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit β€” the [:500] bound was dropped here while stdout above keeps it. _update_failure_detail can get long: the PATH enumeration, up to 500 chars of updater output, plus command/owner/paths. Worth keeping a bound on the API response.

Co-authored-by: multica-agent <github@multica.ai>

@saheljalal saheljalal left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review β€” c69a106

Verdict: Approve β€” all seven findings from the first round are resolved, each with a regression test that pins the behavior. Merge state is CLEAN, CI test is green, and I reproduced the fixes locally (597 passed, 1 skipped; py_compile and git diff --check clean).

Prior findings β€” verified on this commit

# Finding Status
Blocker unchanged collapsed into failed Fixed. execute_agent_update now only rewrites failed/unchanged_outdated, so _do_update_impl's unchanged branch is live again and return 1 if failed or unchanged_outdated no longer counts it. My original repro β€” a current Hermes whose check phrasing isn't recognized β€” now prints version unchanged; update check inconclusive (2.24.0) and returns 0. I also re-ran it with a phrasing outside the widened regex entirely (Nothing to upgrade, mate.): still unchanged, still exit 0, so the fix doesn't depend on the pattern list being exhaustive. Dashboard returns success: true / exit_code: 0 and the JS no longer claims "updated!".
Should-fix -1 conflated spawn failure with timeout Fixed. Pre-spawn failures return -127; the post-communicate exception path correctly keeps -1 (a child did start). Confirmed the manager retry now happens on a spawn failure and still does not happen on a real timeout β€” and test_real_timeout_stops_before_manager_retry uses an actual 30s sleep against a 0.03s timeout rather than a stubbed return value, which is what makes the two cases genuinely distinguishable.
Should-fix native-first over every manager Fixed. if native and manager in (None, "npm", "bun"). Verified a mise-owned claude plans exactly [["mise", "upgrade", "claude"]] with no native attempt, and the characterization test's original intent is restored. Going owner-exclusive rather than owner-first is stricter than I suggested; the rationale in the code comment (a post-failure native write still desyncs tracked files) is sound and test_receipt_owner_handles_updates_without_native_overwrite covers the failed-manager case across all six receipt managers.
Should-fix _maintenance_env() broke npm prefix discovery Fixed. _run_npm restores NPM_CONFIG_PREFIX/NPM_CONFIG_USERCONFIG in both spellings while still dropping injection vars β€” confirmed PYTHONPATH is still removed on the same call. The regression test asserts both the _npm_global_prefixes() and npm_uninstall_cmd consequences, not just the env dict.
Should-fix sudo -n pacman -S --noconfirm Fixed. pacman/AUR are now query-only. Verified with a recording pacman stub that only -Qoq and -Qm are ever invoked and attempts is empty, and the reported reason names the owning package plus the full-system workflow.
Should-fix uncached subprocess probes per dashboard request Fixed. INSTALL_MANAGER_CACHE keyed on the resolved path + stat fingerprint + discovery env, 60s TTL. Verified 5 calls β†’ 1 probe, invalidated by a binary change, and invalidated before and after every update attempt. Returning list(command) / dict(env) copies is the right call given _update_plan mutates env for installer_bin_dir_env.
Nit dashboard stderr unbounded Fixed, with a test asserting exactly 500.

Notes (non-blocking, no action needed for merge)

  • An agent that is genuinely outdated but whose check is inconclusive now exits 0 as unchanged. That's the correct trade against the false-failure blocker and docs/aikit.md says so explicitly ("This does not claim that the installed version is current") β€” worth keeping in mind if the phrasing list ever needs to grow.
  • The unchanged_outdated CLI line now prints the full _update_failure_detail, so the original report's three-agent case will be three paragraphs rather than three one-liners. Fine given the diagnostics are the point; only worth revisiting if it reads as noise in practice.
  • -127 reads as a magic number at aikit:1420. No collision risk (real signal-based returncodes don't reach 127), but a named constant would document the contract that execute_agent_update:2335 depends on.

Nice work on the round β€” the regression suite in tests/test_aikit_update_review.py tests the actual mechanisms (real subprocesses, real timeouts, a recording package-manager stub, probe counting via a marker file) rather than the fix's shape, which is why I could verify each item independently instead of taking the tests' word for it.

Live Omarchy and native Windows/macOS verification is the remaining gap and is going to QA rather than blocking merge.

Co-authored-by: multica-agent <github@multica.ai>

@saheljalal saheljalal left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review β€” Bun delta eec24d8

Verdict: Approve. The invocation is correct against real Bun, the regression pins it properly, and the change is confined to the Bun branch.

Verified against real Bun 1.3.14 (not just the stub)

The previous round's lesson was that a permissive stub can bless an invocation the real tool rejects, so I checked the variable names against the actual binary rather than the docs link:

  • BUN_INSTALL_GLOBAL_DIR="/tmp/.../pkg dir" BUN_INSTALL_BIN="/tmp/.../bin dir" BUN_INSTALL=<decoy> bun install -g cowsay@latest β†’ packages landed in pkg dir, and cowsay/cowthink symlinks landed in bin dir. The decoy BUN_INSTALL was never used, confirming both variables take precedence over $BUN_INSTALL derivation β€” which is what aikit:1736-1739 relies on.
  • The old form reproduces QA's failure exactly: bun install -g is-odd@latest --global-dir ... --global-bin-dir ... β†’ error: Could not find package.json for "file:/tmp/.../bin dir" dependency. So the flags were indeed parsed as package specs; removing them is the right fix, not a workaround.

Verified in aikit itself

Independent probe (not the PR's test), default and relocated layouts, directory names containing spaces, with hostile inherited environment (BUN_INSTALL, BUN_INSTALL_GLOBAL_DIR, BUN_INSTALL_BIN all pointing at decoys, plus NODE_OPTIONS/PYTHONPATH injection):

  • plan β†’ manager: bun, commands: [["bun", "install", "-g", "@oh-my-pi/pi-coding-agent@latest"]] β€” no flags at all
  • the subprocess received BUN_INSTALL_GLOBAL_DIR/BUN_INSTALL_BIN pinned to the detected install, NODE_OPTIONS/PYTHONPATH unset, HOME intact
  • 18.1.15 β†’ 18.1.17, exit 0, same active executable path afterwards; none of the decoy directories were created

aikit:2306 passes plan["env"] into run(), which merges over os.environ and drops None sentinels, so the pin survives _maintenance_env() sanitization β€” the ordering in _update_plan:1957 ({**_maintenance_env(), **env}) is the part that makes this work, and it's correct.

Code-level notes

  • _node_installation gained a third return value; aikit:1852 is the only caller and it was updated, and the npm branch correctly returns {} rather than leaking a mutable default. _manager_installation already copies with dict(env) before returning, so the 60s probe cache can't be poisoned by _update_plan mutating env for installer_bin_dir_env.
  • Cache signature already includes BUN_INSTALL_GLOBAL_DIR (aikit:1808), which is the only environment variable the Bun detection reads β€” so a relocation invalidates the probe. BUN_INSTALL_BIN doesn't need to be in the signature because the bin dir is derived from the resolved binary path, which is already fingerprinted.
  • The regression is genuinely strict: the stub rejects any argv that isn't exactly install -g <pkg>@latest and independently asserts both destination variables, with a conflicting inherited BUN_INSTALL_GLOBAL_DIR in the default-layout case. That's the shape that would have caught the original defect.

Nit (non-blocking): resolve_update_cmd returns only the argv, so the dashboard's update_cmd field (aikit:5867, aikit:5900) now shows bun install -g <pkg>@latest with no indication of the destination. Harmless for aikit's own execution, but a user copy-pasting it for a relocated Bun layout would install to the default location. Worth surfacing the pinned env alongside it if that field is ever presented as a runnable command.

Suite green at eec24d8 (599 passed, 1 skipped), py_compile and git diff --check clean, CI test passing, version correctly coalesced at 2.0.0 with the changelog bullet appended to the existing section.

@saheljalal
saheljalal merged commit f36b584 into main Sep 11, 2026
1 check passed
@saheljalal
saheljalal deleted the pok-387-detect-active-agent-updater branch September 11, 2026 04:05
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