From a33194c5bd59373b229343aca282492dab297924 Mon Sep 17 00:00:00 2001 From: Daniel Ochoa Date: Thu, 22 Oct 2026 11:07:07 -0500 Subject: [PATCH] fix(core): survive dependency graph plugin updates Make prd-graph-helper resolve the newest usable plugin cache at runtime and let a live helper keep using its loaded renderer after its cache is removed. Add command-level and HTTP regression tests for both update failure modes, document the behavior, and bump closedloop-core to 1.5.2. --- CHANGELOG.md | 6 ++ .../.claude-plugin/plugin.json | 2 +- .../closedloop-core/.codex-plugin/plugin.json | 2 +- .../skills/prd-dependency-graph/SKILL.md | 6 +- .../scripts/refresh_server.py | 97 +++++++++++++++++-- .../closedloop-core/tools/python/conftest.py | 25 +++-- .../test_prd_dependency_graph_portable.py | 68 ++++++++++++- 7 files changed, 183 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0041d172..a25f0d91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to the claude-plugins project will be documented in this fil The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Entries are listed newest-first; each plugin section is treated as released when merged to `main`. +### closedloop-core v1.5.2 + +#### Fixed +- `prd-graph-helper` resolves the newest usable `closedloop-core` installation from the Codex and Claude plugin caches each time it runs instead of pinning itself to the versioned cache path that created it. Source checkouts remain a version-aware fallback, so deleting an old plugin cache no longer leaves the launcher unable to start. +- A dependency-graph helper whose plugin cache is removed while it is running keeps refreshing with its renderer already loaded in memory and logs a one-time upgrade hint. Regression tests remove both a launcher's original cache and a live helper's cache before exercising their command and HTTP refresh surfaces. + ### vibe v1.0.75 The open fleet lessons from the 2026-10-21 and 2026-10-22 orchestration runs, each with a mechanical check where one fits, and a watcher bug found while checking them. diff --git a/plugins/closedloop-core/.claude-plugin/plugin.json b/plugins/closedloop-core/.claude-plugin/plugin.json index eb7c4aa3..22ae7553 100644 --- a/plugins/closedloop-core/.claude-plugin/plugin.json +++ b/plugins/closedloop-core/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "closedloop-core", "description": "Shared ClosedLoop planning, intelligence, review, QA, measurement, and PR monitoring", - "version": "1.5.1", + "version": "1.5.2", "author": {"name": "ClosedLoop", "email": "support@closedloop.ai"} } diff --git a/plugins/closedloop-core/.codex-plugin/plugin.json b/plugins/closedloop-core/.codex-plugin/plugin.json index 96065f5f..daae80d1 100644 --- a/plugins/closedloop-core/.codex-plugin/plugin.json +++ b/plugins/closedloop-core/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "closedloop-core", - "version": "1.5.1", + "version": "1.5.2", "description": "Shared ClosedLoop planning, intelligence, review, QA, measurement, and PR monitoring", "author": {"name": "ClosedLoop", "email": "support@closedloop.ai"}, "homepage": "https://github.com/closedloop-ai/claude-plugins", diff --git a/plugins/closedloop-core/skills/prd-dependency-graph/SKILL.md b/plugins/closedloop-core/skills/prd-dependency-graph/SKILL.md index ce38019a..492d917c 100644 --- a/plugins/closedloop-core/skills/prd-dependency-graph/SKILL.md +++ b/plugins/closedloop-core/skills/prd-dependency-graph/SKILL.md @@ -84,8 +84,8 @@ Your job is to pick the scope and options, run them, name the columns, and check The graph's top-right corner has zoom controls, a gear (options), and a refresh icon. The gear and refresh icon need the local helper running. -- **Started for the user:** `--new` and `--once` start the helper in the background when it is not running, and replace a helper left running from another install of this skill (for example after a plugin update). `--ensure` does only that. The helper's log is `~/.local/share/prd-dependency-graph/helper.log`. -- **Starting it by hand** (for example after a reboot): `prd-graph-helper`. Every run writes that launcher to `~/.local/bin/prd-graph-helper`, pointing at the installed `scripts/refresh_server.py`, so the command survives plugin updates even though the installed path changes with each version. The page's "helper not running" dialog shows `prd-graph-helper`, or `~/.local/bin/prd-graph-helper` when that folder is not on PATH. A file already at that path that the skill did not write is left alone. If an older helper is running, the dialog says it is out of date and shows `prd-graph-helper --ensure`, which replaces it. +- **Started for the user:** `--new` and `--once` start the helper in the background when it is not running, and replace a helper left running from another install of this skill (for example after a plugin update). `--ensure` does only that. A running helper whose versioned plugin cache is removed keeps using its renderer already in memory and writes one upgrade hint to the log instead of failing refreshes. The helper's log is `~/.local/share/prd-dependency-graph/helper.log`. +- **Starting it by hand** (for example after a reboot): `prd-graph-helper`. Every run writes that launcher to `~/.local/bin/prd-graph-helper`. The launcher resolves the newest usable `closedloop-core` cache in Codex or Claude at execution time and falls back to the install that wrote it, so the command survives plugin updates even though installed paths change with each version. The page's "helper not running" dialog shows `prd-graph-helper`, or `~/.local/bin/prd-graph-helper` when that folder is not on PATH. A file already at that path that the skill did not write is left alone. If an older helper is running, the dialog says it is out of date and shows `prd-graph-helper --ensure`, which replaces it. - **Where it listens:** `127.0.0.1:8765` only (the graph's `port`). - **What a refresh does:** - the page POSTs its graph id and scope to `/v2/refresh`, and, when it was opened from a file, the file's own path; @@ -95,7 +95,7 @@ The graph's top-right corner has zoom controls, a gear (options), and a refresh - **Pages that are not files** (for example a report served from `http://localhost`): the page sends its config instead of a path, the helper sends the new graph back, the page swaps it in place, and the dialog says only that view was updated and no file on disk changed. Pages on any other website cannot refresh. - **Options (gear):** one checkbox, "Show the full chain of outside blockers". Flipping it fetches and redraws immediately (no separate refresh) and saves `outside_blockers_depth` (`"all"` or `1`) in the page. If the helper is not running, the checkbox flips back and a dialog explains. Add future options to this menu. - **Dialogs:** after a refresh, a dialog lists what changed (status, owner, tickets or links added or removed) or says nothing changed. If the helper is not running, a dialog says so and shows the start command. Other failures also show in a dialog. -- **Styles and scripts** travel inside the refreshed block, so a page made by an older renderer picks up the new look after one refresh. The helper reloads `render_graph.py` on every refresh. Changes to `refresh_server.py` in the same install need a helper restart (stop it, then `--ensure`); a helper from a different install is replaced by the next `--new`, `--once` or `--ensure`. +- **Styles and scripts** travel inside the refreshed block, so a page made by an older renderer picks up the new look after one refresh. The helper reloads `render_graph.py` on every refresh while that install exists. If a plugin update removes the install underneath a running helper, it keeps using the loaded renderer until `prd-graph-helper --ensure` replaces it. Changes to `refresh_server.py` in the same install need a helper restart (stop it, then `--ensure`); a helper from a different install is replaced by the next `--new`, `--once` or `--ensure`. - **Other commands:** - `--once ` refreshes a page or report from the terminal without the server (`--graph ` picks one graph in a report). `--once ` refreshes an older graph from its config file. - `--export-config ` prints the config a page carries. diff --git a/plugins/closedloop-core/skills/prd-dependency-graph/scripts/refresh_server.py b/plugins/closedloop-core/skills/prd-dependency-graph/scripts/refresh_server.py index af4d119c..f2abe951 100644 --- a/plugins/closedloop-core/skills/prd-dependency-graph/scripts/refresh_server.py +++ b/plugins/closedloop-core/skills/prd-dependency-graph/scripts/refresh_server.py @@ -7,9 +7,9 @@ prd-graph-helper -That launcher, in ~/.local/bin, is rewritten on every run to point at this -script, so it keeps working when the plugin updates. `--ensure` starts the -helper in the background and exits; its log is +That launcher, in ~/.local/bin, resolves the newest installed copy of this +script each time it runs, so it keeps working when a versioned plugin cache is +replaced. `--ensure` starts the helper in the background and exits; its log is ~/.local/share/prd-dependency-graph/helper.log. Pages are portable. Each graph block carries its own config (scope, options, @@ -85,6 +85,7 @@ LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"} ME_TTL_SECONDS = 600 LOG_HINT = "~/.local/share/prd-dependency-graph/helper.log" +_RENDERER_SOURCE_WARNING_WRITTEN = False def api_key(): @@ -471,13 +472,37 @@ def config_path(graph_id): return DATA_DIR / f"{graph_id}.json" +def reload_renderer() -> None: + """Reload the renderer when its install still exists, otherwise keep the loaded copy. + + Plugin managers may remove a versioned cache directory while this helper is + still running. The module already in memory is complete, so losing its source + must not turn every subsequent page refresh into a 500 response. + """ + global _RENDERER_SOURCE_WARNING_WRITTEN + source_name = getattr(render_graph, "__file__", None) + source = Path(source_name) if isinstance(source_name, str) and source_name else None + if source is not None and source.is_file(): + try: + importlib.reload(render_graph) # pick up renderer edits within the same install + return + except ModuleNotFoundError: + # The cache may have disappeared between the existence check and reload. + if source.is_file(): + raise + if not _RENDERER_SOURCE_WARNING_WRITTEN: + sys.stderr.write("refresh-helper: the plugin install was removed; continuing with the loaded renderer. " + "Run prd-graph-helper --ensure to start the newest installed helper.\n") + _RENDERER_SOURCE_WARNING_WRITTEN = True + + def refresh(graph_id, token=None, outside=None, gate=None) -> dict[str, Any]: """Refresh a graph that still has a config file (made before pages carried their own).""" path = config_path(graph_id) if not path.exists(): raise FileNotFoundError(f"no graph config at {path}") cfg = json.loads(path.read_text(encoding="utf-8")) - importlib.reload(render_graph) # pick up renderer edits without restarting the helper + reload_renderer() if token is not None and not hmac.compare_digest(str(cfg["refresh"].get("token", "")), token): raise PermissionError("bad token") if outside is not None: @@ -650,7 +675,7 @@ def refresh_file(path, graph_id=None, scope=None, outside=None, replacement=None real, text, info = read_target(path) # Errors name the file as the request gave it: a symlink's target name is not the requester's to learn. name = Path(path).name - importlib.reload(render_graph) + reload_renderer() graph_id = choose_graph(text, graph_id, name) embedded = render_graph.read_embedded(text, graph_id) if scope is not None and (embedded is None or str((embedded.get("refresh") or {}).get("scope")) != scope): @@ -978,11 +1003,65 @@ def main(): pass -def install_launcher(): - """Write ~/.local/bin/prd-graph-helper pointing at this script; rewritten when the script moves.""" +def launcher_body() -> str: + """Return a launcher that resolves the newest surviving plugin cache at execution time.""" + try: + fallback_version = json.loads((SCRIPT.parents[3] / ".claude-plugin" / "plugin.json") + .read_text(encoding="utf-8")).get("version") + except (OSError, ValueError, AttributeError): + fallback_version = None + if not isinstance(fallback_version, str): + fallback_version = "0.0.0-dev" + return f'''#!/usr/bin/env python3 +{LAUNCHER_MARK}: written by the prd-dependency-graph skill; resolves plugin updates at runtime. +import os +import re +import sys +from pathlib import Path + +FALLBACK = {str(SCRIPT)!r} +FALLBACK_VERSION = {fallback_version!r} +PLUGIN_CACHE = Path("plugins/cache/closedloop-ai/closedloop-core") +HELPER = Path("skills/prd-dependency-graph/scripts/refresh_server.py") + + +def version_key(name): + match = re.fullmatch(r"(\\d+)\\.(\\d+)\\.(\\d+)(?:-([0-9A-Za-z.-]+))?", name) + if not match: + return (-1, -1, -1, 0, name) + prerelease = match.group(4) + return (*(int(match.group(i)) for i in range(1, 4)), 1 if prerelease is None else 0, prerelease or "") + + +roots = [ + Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex"), + Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude"), +] +candidates = [] +if Path(FALLBACK).is_file(): + candidates.append((version_key(FALLBACK_VERSION), 1, FALLBACK)) +for root in roots: + cache = root.expanduser() / PLUGIN_CACHE + try: + installs = list(cache.iterdir()) + except OSError: + continue + for install in installs: + helper = install / HELPER + if helper.is_file(): + candidates.append((version_key(install.name), 0, str(helper))) + +if not candidates: + raise SystemExit("prd-graph-helper: no installed dependency-graph helper was found; run the skill once to repair it") +script = max(candidates)[2] +os.execv(sys.executable, [sys.executable, script, *sys.argv[1:]]) +''' + + +def install_launcher() -> None: + """Write ~/.local/bin/prd-graph-helper without pinning it to one cache version.""" launcher = render_graph.LAUNCHER - body = (f"#!/bin/sh\n{LAUNCHER_MARK}: written by the prd-dependency-graph skill, rewritten on every run.\n" - f"exec python3 {shlex.quote(str(SCRIPT))} \"$@\"\n") + body = launcher_body() try: if launcher.exists(): current = launcher.read_text(encoding="utf-8", errors="replace") diff --git a/plugins/closedloop-core/tools/python/conftest.py b/plugins/closedloop-core/tools/python/conftest.py index 3d5ae43f..10fe9d1e 100644 --- a/plugins/closedloop-core/tools/python/conftest.py +++ b/plugins/closedloop-core/tools/python/conftest.py @@ -11,6 +11,7 @@ import sys import threading import urllib.request +from contextlib import contextmanager from http.server import ThreadingHTTPServer from pathlib import Path from types import SimpleNamespace @@ -109,7 +110,6 @@ def graph(tmp_path, monkeypatch, api): server = importlib.import_module("refresh_server") monkeypatch.setattr(server, "Api", api.client) monkeypatch.setattr(server, "api_key", lambda: API_KEY) - monkeypatch.setattr(server, "install_launcher", lambda: None) monkeypatch.setattr(server, "ensure_helper", lambda port: "helper not started in tests") monkeypatch.setattr(server, "GATE", server.Gate()) monkeypatch.setattr(urllib.request, "urlopen", _no_network) @@ -118,10 +118,10 @@ def graph(tmp_path, monkeypatch, api): sys.modules.pop(name, None) -@pytest.fixture -def helper(graph): - """A helper serving on a free loopback port in this process; yields a request function.""" - httpd = ThreadingHTTPServer(("127.0.0.1", 0), graph.server.Handler) +@contextmanager +def serving(server): + """Serve one loaded refresh-helper module on a free loopback port.""" + httpd = ThreadingHTTPServer(("127.0.0.1", 0), server.Handler) thread = threading.Thread(target=httpd.serve_forever, daemon=True) thread.start() port = httpd.server_address[1] @@ -150,9 +150,18 @@ def request(method, path, body=None, origin="null", content_type="application/js return SimpleNamespace(status=resp.status, headers={k.lower(): v for k, v in resp.getheaders()}, raw=payload, json=json.loads(payload) if payload else None) - yield request - httpd.shutdown() - httpd.server_close() + try: + yield request + finally: + httpd.shutdown() + httpd.server_close() + + +@pytest.fixture +def helper(graph): + """A helper serving on a free loopback port in this process; yields a request function.""" + with serving(graph.server) as request: + yield request def new_graph(graph, scope, *, output=None, embed_into=None, graph_id=None, outside_depth="1"): diff --git a/plugins/closedloop-core/tools/python/test_prd_dependency_graph_portable.py b/plugins/closedloop-core/tools/python/test_prd_dependency_graph_portable.py index 434ce3df..024053be 100644 --- a/plugins/closedloop-core/tools/python/test_prd_dependency_graph_portable.py +++ b/plugins/closedloop-core/tools/python/test_prd_dependency_graph_portable.py @@ -5,9 +5,10 @@ import shutil import subprocess import sys +from types import SimpleNamespace import pytest -from conftest import SCRIPTS, embedded, new_graph, refresh_body +from conftest import API_KEY, FakeClosedLoop, SCRIPTS, embedded, new_graph, refresh_body, serving LEGACY_PAGE = ('

PRD-9 dependency graph

' '