fix(pm): review fixes — enabled-state union, recency tiebreak, gated bridge, sync authority

Spec + standards review (2-subagent /code-review) found five real
gaps against the settled design; all fixed:

- enabled_member_dirs() now FILTERS by enabled state: only plugins in
  some profile's plugins.enabled join the union (a disabled plugin
  never syncs). Result is ENABLE-RECENCY-ORDERED (newest last) via
  new pm/plugins_state.py — order-preserving reads of every profile's
  enabled list — so the bisect's incumbent-wins tiebreak (pop last)
  now disables the most-recently-enabled, not the alphabetical last.
- materialize_legacy_pyproject() is gated on lazy_installs_allowed():
  lazy-off installs keep the plugin dir untouched (materializing
  would create a member candidate and then hard-fail every sealed
  sync). Settled: 'never runs when lazy installs are disabled'.
- plugins_cmd dep install routes through resolve_union + the lazy
  gate (it previously drove lock_and_sync directly, bypassing both):
  the would-be union resolves as a check before enable, with the
  plugin's own bisect decision surfaced as the refusal reason. The
  real sync after enable still runs through sync_venv (the one
  authority, with receipt + write-back).
- resolve_union disable decisions are written back to the plugins
  enabled config (record_disabled_plugins → pm.plugins_state.
  disable_plugins) so hermes plugins list reflects reality and
  re-enable retries; best-effort, never breaks the sync.

tests: member discovery now asserts enabled-filter + recency order +
orphan exclusion; plugins_state suite (5: cross-home reads, order
preservation, disable write-back across homes, noop, garbage-config);
materialize lazy-off test; deps_flow updated to the resolve_union
shape. tests/pm + deps_flow: 193 passed, 0 failed.
This commit is contained in:
ethernet
2026-09-02 20:21:00 -04:00
parent 07eab19bcc
commit ad433f8029
7 changed files with 387 additions and 25 deletions

View File

@@ -420,7 +420,7 @@ class Venv(StatePackage):
# conflict, resolve_union bisects: fail-alone plugins and
# mutual-conflict losers (incumbent wins) are dropped with
# their resolver reasons, and the union retries.
from pm.workspace import resolve_union
from pm.workspace import record_disabled_plugins, resolve_union
import logging
@@ -433,6 +433,16 @@ class Venv(StatePackage):
decision["plugin"],
decision["reason"],
)
# Write bisect disables back to the plugins enabled config —
# `hermes plugins list` must reflect reality and re-enable
# must retry. Best-effort: a config write failure never
# breaks the sync.
try:
record_disabled_plugins(decisions)
except Exception:
logging.getLogger(__name__).warning(
"could not persist bisect disable decisions", exc_info=True
)
# Receipt: the machine-readable surface for this rebuild
# (same schema/dir as update receipts; the updater embeds
# these sections via pm.receipt.snapshot()).