diff --git a/README.md b/README.md index ba0d1d45..eab92e7f 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,9 @@ Any other agent that speaks MCP takes one entry in its MCP config; see [Support for agents](#support-for-agents). Start a new agent session afterward: plugins are loaded at startup. Add `.axiomcode/` to your `.gitignore`; the graph is built there on first use. +After upgrading the engine or the plugin, **restart the agent session**: a session started before the upgrade keeps the +old version's MCP server and hooks running beside the new ones, and the two rebuild the graph in turn. + ### Uninstallation ```bash diff --git a/plugins/axiomcode/hooks/_host.py b/plugins/axiomcode/hooks/_host.py index e133112a..5ae1b733 100644 --- a/plugins/axiomcode/hooks/_host.py +++ b/plugins/axiomcode/hooks/_host.py @@ -34,10 +34,22 @@ GEMINI = None # the Gemini event name this hook was called for, once read() has seen one -def read(): - """the event from stdin, with Cursor's or Gemini's names mapped onto the ones the hooks use""" +def orphaned(): + """ax_orphan.orphaned for the install these hooks belong to: '' while it is current""" + try: + sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'skills', 'axiomcode', 'scripts')) + import ax_orphan + return ax_orphan.orphaned() + except Exception: + return '' + +def read(orphan_ok=False): + """the event from stdin, with Cursor's or Gemini's names mapped onto the ones the hooks use. A hook of an ORPHANED + older install (ax_orphan.orphaned: an agent session that outlived an upgrade) exits here, silent, so it writes nothing + into a repository's .axiomcode; only the refresh hook (orphan_ok) goes on, to say at a session start and on a prompt that the session wants a restart""" global GEMINI ev = json.load(sys.stdin) + if not orphan_ok and orphaned(): sys.exit(0) if isinstance(ev, dict) and ev.get('hook_event_name') in GEMINI_EVENTS: GEMINI = ev['hook_event_name'] ev['hook_event_name'] = GEMINI_EVENTS[GEMINI] diff --git a/plugins/axiomcode/hooks/refresh.py b/plugins/axiomcode/hooks/refresh.py index e5798d22..87bf297e 100644 --- a/plugins/axiomcode/hooks/refresh.py +++ b/plugins/axiomcode/hooks/refresh.py @@ -14,7 +14,7 @@ import _host, _where try: - ev = _host.read() + ev = _host.read(orphan_ok=True) # an orphaned install's hook only says so (ax_fresh.hook_kick) except Exception: sys.exit(0) cwd = ev.get('cwd') or os.getcwd() diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index 27c4c357..b1ebc5e6 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -164,10 +164,26 @@ async def call_tool(self, name, arguments, *a, **k): # the SDK: refused as a # the flags whose next argument is their value, as the dispatcher skips them when it looks for the repository VALUED = {'--in', '--from', '--budget', '--seeds', '--depth', '--limit', '--tests-in', '--kind', '--range', '--old', '--new', '--file', '--page', '--page-budget', '--out'} +def orphaned(root=None): + """ax_orphan.orphaned for the install this server runs now (plugin_root): '' while it is current. An install whose + scripts are gone cannot be imported, so that is said here""" + root = root or plugin_root() + try: return scripts_module('ax_orphan').orphaned(root) + except Exception: + if os.path.isfile(os.path.join(root, 'skills', 'axiomcode', 'scripts', 'ax_fresh.py')): return '' + return (f"this is an orphaned older axiomcode (its install directory {root} is gone: a newer version was installed in " + "its place), still running in an agent session started before the upgrade; it writes nothing into the " + "repository's .axiomcode and rebuilds nothing. Restart the agent session so that only the installed axiomcode runs") + def _timer(interval): import time while True: time.sleep(max(1.0, min(60.0, interval / 3))) + # AN ORPHANED SERVER STOPS (ax_orphan.orphaned): its install was replaced and no newer one is recorded to run in its + # place, so all it could still do is rebuild graphs with the older version, the turn-taking an upgrade started + o = orphaned() + if o: + sys.stderr.write(f"axiomcode mcp: {o}. This server stops.\n"); sys.stderr.flush(); os._exit(0) for repo in list(SEEN): try: fresh = scripts_module('ax_fresh') @@ -183,6 +199,9 @@ def run(args, cwd=None, timeout=900): env = dict(os.environ, AXIOMCODE_BUILD_NOWAIT='1', AXIOMCODE_SURFACE='mcp') if args and args[0] != 'index' else None root = plugin_root() ax = os.path.join(root, 'skills', 'axiomcode', 'scripts', 'axiomcode') + # an orphaned older install answers with the restart and runs nothing: its verbs would write into .axiomcode + o = orphaned(root) + if o: return f"axiomcode: {o}." note = stale_note(root) head = (note + '\n') if note else '' try: @@ -272,6 +291,9 @@ def tests() -> str: if __name__ == '__main__': # catch up on whatever changed while no session was running (#1305): started, never waited on + o = orphaned() + if o: + sys.stderr.write(f"axiomcode mcp: {o}. Not starting.\n"); sys.exit(1) try: scripts_module('ax_fresh').hook_kick(os.getcwd(), 'the MCP server starting') if os.path.isdir(os.path.join(os.getcwd(), '.axiomcode')): SEEN.add(os.path.realpath(os.getcwd())) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index e1fc0104..1958b7e3 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -652,10 +652,14 @@ def newer_build(repo, t=None): IMPACT_VERSION is not re-exported either (rewarm): that would record the older version over the newer one's""" return built_by_state(repo, t)[1] +TWO_VERSIONS = ("two axiomcode versions are running over this repository (an agent session started before an upgrade keeps " + "the older version's hooks and MCP server); restart the agent session after upgrading the engine or the plugin") + def newer_note(n): - """what an answer from a graph a newer axiomcode built says""" - return (f"graph refresh: {n}; answering from it as is, and this older axiomcode does not rebuild it; rows in files " - "edited since are marked. Update this axiomcode, or `AXIOMCODE_REINDEX=1 axiomcode index` rebuilds it with this one") + """what an answer from a graph a newer axiomcode built says: most often the older of TWO VERSIONS RUNNING at once (an + agent session that outlived an upgrade), which a restart ends, not a stale graph""" + return (f"graph refresh: {n}: {TWO_VERSIONS}. This older axiomcode answers from that graph as it is and does not rebuild " + "it; rows in files edited since are marked. Update it, or `AXIOMCODE_REINDEX=1 axiomcode index` rebuilds the graph with this one") def engine_change(repo, t=None): """'' when the graph was built by the axiomcode that would build it now, else what differs, written @@ -676,6 +680,54 @@ def export_behind(t): if _vnum(have) and _vnum(now) and _vnum(have) > _vnum(now): return '' return have +# ── TWO VERSIONS TAKING TURNS: REBUILT ONCE, NOT ON EVERY TURN ───────────────────────────────────────────────────────── +# NEVER A DOWNGRADE (newer_build) holds only when the older axiomcode knows it: one from before it (or one whose version +# numbers tie with this one's) rebuilds the graph with its own engine, this one rebuilt it back, and every answer between +# came from whichever had won last (measured: IMPACT_VERSION 41 -> 76 and back, a full rebuild on each turn). So a build +# that replaces a graph another axiomcode built records that graph's maker (displace); when a graph by that same maker +# turns up again AFTER that, both are still running, and this one does not rebuild it again in the background: it says +# that two versions are running and that a restart of the agent session ends it (contested). `axiomcode index` still +# rebuilds it: asked for by name, it is not a turn. +DISPLACED_KEEP = 8 + +def build_key(b): + """a short identity of what built a graph (its file table's built_by): every axiomcode that writes it differently differs""" + if not isinstance(b, dict): return 'unrecorded' + keep = {k: b.get(k) for k in ('impact', 'rules', 'index', 'engine_version', 'engine_hash')} + return hashlib.sha1(json.dumps(keep, sort_keys=True).encode()).hexdigest()[:16] + +def displace(repo, t): + """a build of this axiomcode's is about to replace the graph table t describes, which another axiomcode built""" + if not t: return + d = dict(read_state(repo).get('displaced') or {}) + d[build_key(t.get('built_by'))] = time.time() + write_state(repo, displaced=dict(sorted(d.items(), key=lambda kv: kv[1])[-DISPLACED_KEEP:])) + +def _maker(b): + if not isinstance(b, dict): return 'an axiomcode that did not record its version' + return f"axiomcode {b.get('engine_version') or '?'} (IMPACT_VERSION {b.get('impact') or '?'})" + +def contested(repo, t=None, older=None): + """'' unless the graph (an older axiomcode's: `older` is engine_change's line) was built by an axiomcode this one had + already rebuilt it over, after that rebuild: then one line naming both, that two versions are running, and that this + one does not rebuild it again in the background""" + t = t if t is not None else load_table(repo) + older = engine_change(repo, t) if older is None else older + if not t or not older: return '' + when = (read_state(repo).get('displaced') or {}).get(build_key(t.get('built_by'))) + built = t.get('built') + if built is None: + try: built = os.path.getmtime(table_path(repo)) + except OSError: built = 0 + if not when or float(built) <= float(when): return '' + eng = current_engine(repo); mine = f"IMPACT_VERSION {plugin_id()[1]}" + if eng: + try: mine = f"{json.load(open(os.path.join(eng, 'package.json'))).get('version', '?')} ({mine})" + except (OSError, ValueError): pass + return (f"this graph was rebuilt by {_maker(t.get('built_by'))} after this axiomcode {mine} had rebuilt it: {TWO_VERSIONS}. " + "This one does not rebuild it again in the background, so the two stop rebuilding it in turn; once only one runs, " + "`axiomcode index` rebuilds it once") + def _seen_hash(repo, e): """the content hash of engine e: an engine whose files moved (a reinstall, a new checkout) but whose bytes did not is hashed once, and its hash kept beside the graph for its stat signature, so later queries only stat it""" @@ -695,12 +747,16 @@ def read_state(repo): except (OSError, ValueError): return {} def write_state(repo, **kv): + if orphaned(): return # an orphaned older install writes nothing (orphaned) st = read_state(repo); st.update(kv) tmp = state_path(repo) + f'.{os.getpid()}' try: json.dump(st, open(tmp, 'w')); os.replace(tmp, state_path(repo)) except OSError: pass +# ── AN ORPHANED INSTALL WRITES NOTHING: ax_orphan.orphaned, the one check every path that writes unasked makes ────────── +from ax_orphan import ORPHAN_MARK, install_dir, orphaned + def has_graph(repo): """a graph was built here: graph.sqlite exists, or is a pointer whose target is gone. A broken pointer is a graph to REPAIR, not a repository without one: taken for the latter, the refresher stopped for good and the next query @@ -717,7 +773,7 @@ def relink(repo, locked=False, say=True): interrupted". A pointer that leaves this .axiomcode/out is re-pointed at the same graph here (the copy's own), and says which case it was; one with no graph here to take is removed, so the query builds one. Returns what it did.""" out = out_dir(repo); p = os.path.join(out, 'graph.sqlite') - if not os.path.islink(p) or (not locked and building(repo)): return '' # a running build owns the pointer + if not os.path.islink(p) or (not locked and building(repo)) or orphaned(): return '' # a running build owns the pointer try: t = os.readlink(p) except OSError: return '' if not os.path.isabs(t): return '' # relative: this out/'s own; dangling, it is an interrupted build to repair @@ -963,6 +1019,8 @@ def status(repo): return dict(state='stale', changed=[unbuilt_row(repo)], added=[], removed=[], **nw) if unbuilt(repo) else dict(state='fresh', **nw) d = dict(state='building' if busy else 'stale', changed=changed, added=added, removed=removed, **nw) if eng: d['engine'] = eng + two = contested(repo, t, eng) if eng else '' + if two: d['contested'] = two if busy and not (changed or added or removed): d.update(pending(repo)) if not busy and failed_on(st, c, eng): d['failed'] = st.get('failed_log', ''); d['failed_reason'] = st.get('failed_reason', '') return d @@ -983,7 +1041,7 @@ def behind(s): return bool(edited(s) or s.get('engine')) def enabled(repo): - return not os.environ.get('AXIOMCODE_NO_REFRESH') and not os.environ.get('AXIOMCODE_GRAPH') and has_graph(repo) + return not os.environ.get('AXIOMCODE_NO_REFRESH') and not os.environ.get('AXIOMCODE_GRAPH') and has_graph(repo) and not orphaned() def refresh_off(repo): """the refresher is switched off (AXIOMCODE_NO_REFRESH) over a graph of this repository's own: nothing rebuilds it, @@ -1071,7 +1129,10 @@ def hook_kick(repo, trigger, session='', event='', tool=''): """what a hook (and the MCP server's timer) runs in place of kick: '' after starting the refresher as kick does, or, for a graph another axiomcode built (engine_change), nothing started and one line to say so, once per session and difference ('' when it was said already). A graph a newer axiomcode built is never rebuilt anyway (newer_build). - `event` and `tool` name the hook event and the tool it followed, for batching edits (batch_held)""" + `event` and `tool` name the hook event and the tool it followed, for batching edits (batch_held). An orphaned older + install (orphaned) starts and writes nothing, and says so at a session's start and on a prompt""" + o = orphaned() + if o: return f"graph refresh: {o}" if event in ('SessionStart', 'UserPromptSubmit') and has_graph(repo) else '' if not enabled(repo): return '' held = engine_change(repo) if not held: @@ -1080,6 +1141,8 @@ def hook_kick(repo, trigger, session='', event='', tool=''): key = f"{session}|{held}" if read_state(repo).get('hook_held') == key: return '' write_state(repo, hook_held=key) + two = contested(repo, None, held) + if two: return f"graph refresh: {two}" return (f"graph refresh: {held}; the hooks do not rebuild a graph another axiomcode built, so it is kept as it is. " f"`axiomcode index` rebuilds it with engine {engine_label(repo)}, and so does a query, which says so on its first " f"line; {NO_REFRESH_HOW}") @@ -1189,6 +1252,7 @@ def rewarm(repo, t): except OSError: pass def worker(repo): + if orphaned(): return 0 # an orphaned older install builds nothing (orphaned) lock = os.path.join(repo, '.axiomcode', 'refresh.lock') fd = os.open(lock, os.O_RDWR | os.O_CREAT, 0o644) if not _flock(fd, False): return 0 # single flight @@ -1219,6 +1283,12 @@ def worker(repo): # BUILT BY ANOTHER AXIOMCODE: rebuilt with no file changed, once per worker. A rebuild that leaves the same # difference behind (a build that could not record what built it) is not repeated eng = engine_change(repo, t) + two = contested(repo, t, eng) if eng else '' + if two: + # TWO VERSIONS TAKING TURNS: this one rebuilt it once already, and the other rebuilt it back + write_state(repo, state='contested', checked=time.time(), contested=two, checked_by=os.environ.get('AXIOMCODE_REFRESH_TRIGGER', '')) + print(f"{time.strftime('%H:%M:%S')} refresh: {two}", flush=True) + return 0 if eng and eng == engine_done: eng = '' if (c is None or not any(c)) and not eng and not base_moved(repo): if export_behind(t): rewarm(repo, t) @@ -1232,6 +1302,7 @@ def worker(repo): why = (f"{n} file(s) changed" if n else eng if eng else f"HEAD moved to {(head(repo) or '')[:10]}") + \ (f"; {eng}" if n and eng else '') + f", found by {os.environ.get('AXIOMCODE_REFRESH_TRIGGER') or 'an edit'}" engine_done = eng + if eng: displace(repo, t) env = rebuild_env(t, AXIOMCODE_BACKGROUND='1', AXIOMCODE_REFRESH_REASON=why) slot = take_slot(repo) # queued behind the machine's other background builds t0 = time.time(); write_state(repo, state='building', started=t0, files=sum(len(x) for x in c), edits=0) @@ -1281,7 +1352,7 @@ def wait(repo, seconds): if s['state'] == 'unknown': kick(repo, 'a query'); return s # a graph from before the file table: its first refresh records one # a FIRST build's other languages are not waited for: there is no graph of theirs to refresh, only a compile # that can take an hour (#1555). A rebuild's are, for the bounded while any refresh is. - if s['state'] in ('fresh', 'no graph') or s.get('failed') or s.get('first') or s.get('newer'): return s + if s['state'] in ('fresh', 'no graph') or s.get('failed') or s.get('first') or s.get('newer') or s.get('contested'): return s if s['state'] == 'stale': kick(repo, 'a query') # idempotent: a no-op while a worker holds its lock if time.time() >= end: return s time.sleep(0.5) @@ -1292,6 +1363,8 @@ def wait_baseline(repo, seconds, hook=False): hook: asked by a hook, which never rebuilds a graph another axiomcode built (hook_kick)""" if not enabled(repo) or not base_moved(repo): return '' if hook and engine_change(repo): return '' + two = contested(repo) + if two: return f"graph refresh: HEAD moved since the baseline was set ({head(repo)[:10]}), and {two}" if newer_build(repo): return (f"graph refresh: HEAD moved since the baseline was set ({head(repo)[:10]}), and the graph was built by a newer " "axiomcode, which this one does not rebuild: the baseline stays where it was, so this answer also counts what the " @@ -1350,6 +1423,9 @@ def note(s, marked=None, named=None, off=False): if s.get('state') not in ('stale', 'building'): return first if s.get('engine'): # BUILT BY ANOTHER AXIOMCODE: every row may differ from what this version answers, so none is marked; the line says so + if s.get('contested'): + return (first + '\n' if first else '') + f"graph refresh: {s['contested']}; this answer is from that graph, " \ + "and may lack what this version finds" + (f"; it predates edits to {', '.join(edited(s)[:5])}" if edited(s) else '') if off: says = "refresh is OFF (--no-refresh, or AXIOMCODE_NO_REFRESH is set), so nothing rebuilds it; `axiomcode index` does" elif s.get('failed'): says = "the rebuild FAILED" + (f" — {s['failed_reason']}" if s.get('failed_reason') else '') + f" (see {s['failed']})" else: says = "rebuilding in the background; --fresh waits for the rebuild" @@ -1536,7 +1612,7 @@ def wait_fresh(repo, seconds, say=False, files=()): while True: s = status(repo) if (s['state'] in ('fresh', 'no graph', 'unknown') or not behind(s)) and not _waits_on(s, files): return True - if s.get('failed') or s.get('newer'): return False # nothing here rebuilds a newer axiomcode's graph + if s.get('failed') or s.get('newer') or s.get('contested'): return False # nothing here rebuilds a newer axiomcode's graph if s['state'] == 'stale': kick(repo, 'a query') now = time.time() if now >= end: return False @@ -1573,6 +1649,11 @@ def with_note(n): # the answer as it is, then # REFRESH SWITCHED OFF IS NOT "UP TO DATE". With AXIOMCODE_NO_REFRESH nothing rebuilds the graph, which is exactly when # an answer from it most needs to say which edits it predates: a "nothing named X" for an X an edit just added read # as X not existing. It is checked, marked and noted as below; it only never kicks a refresh or waits for one. + o = orphaned() + if o: + # AN ORPHANED OLDER INSTALL answers nothing of its own: its verbs would write their caches, or a first build, into + # .axiomcode with the older version's rules (orphaned) + print(f"graph refresh: {o}.", flush=True); return 0 off = not enabled(repo) and refresh_off(repo) if not enabled(repo) and not off: # the refresh switched off still leaves a build that is solving the repository's other languages (#1555); a @@ -1592,7 +1673,7 @@ def with_note(n): # the answer as it is, then if not note(s): passthrough() return with_note(note(s)) # a graph a newer axiomcode built is never rebuilt here: no refresh is started or waited for, as with refresh off - hold = off or bool(s.get('newer')) + hold = off or bool(s.get('newer')) or bool(s.get('contested')) started = not s.get('failed') and not hold and kick(repo, 'a query') # a refresher already running (a hook's, the timer's, another query's) replaces this graph all the same: said too running = not started and not s.get('failed') and not hold and refresher_running(repo) @@ -1631,6 +1712,7 @@ def with_note(n): # the answer as it is, then obj['freshness'] = dict(state='off' if off else s['state'], edited=edited(s), rows_marked=n, **({'built_by': s['engine']} if s.get('engine') else {}), **({'newer': s['newer']} if s.get('newer') else {}), + **({'contested': s['contested']} if s.get('contested') else {}), **({'built_with': s['built_with']} if s.get('built_with') else {}), **({'failed': s['failed']} if s.get('failed') else {}), **({'named_in_edits': [dict(name=a, file=b) for a, b in missed]} if missed else {})) @@ -1698,7 +1780,7 @@ def main(argv): # the files are the graph's; the axiomcode that built it must be the one building now (axiomcode-build passes # the engine it found as AXIOMCODE_ENGINE), or the graph is rebuilt, saying why eng = engine_change(repo, t) - if eng: print(f"{eng}; rebuilding", flush=True); return 1 + if eng: displace(repo, t); print(f"{eng}; rebuilding", flush=True); return 1 return 0 if cmd == 'status': s = status(repo) @@ -1721,7 +1803,7 @@ def main(argv): # what axiomcode-build asks before it rebuilds: exit 0, saying why, when the graph here was built by a newer # axiomcode, which this one must not rebuild (newer_build); 1 otherwise n = newer_build(repo) - if n: print(f"{n}: kept, not rebuilt by this older axiomcode; update it, or AXIOMCODE_REINDEX=1 rebuilds the graph with this one") + if n: print(f"{n}: kept, not rebuilt by this older axiomcode; {TWO_VERSIONS}. Update it, or AXIOMCODE_REINDEX=1 rebuilds the graph with this one") return 0 if n else 1 if cmd == 'baseline': n = wait_baseline(repo, float(argv[3]) if len(argv) > 3 else 30) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_orphan.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_orphan.py new file mode 100644 index 00000000..b9148d80 --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_orphan.py @@ -0,0 +1,46 @@ +"""ax_orphan.py: is the axiomcode running here an orphaned older install? One check, for every long-running path. + +An agent session started before an upgrade keeps running the OLD version's MCP server and hooks. A plugin manager that +keeps one directory per version marks the one it replaced with `.orphaned_at` (and deletes its files later); a process +still running out of it is an older axiomcode, and what it wrote into a repository's .axiomcode was the older version's: +it rebuilt the graph back to its own engine after the new one had rebuilt it, every rebuild undoing the last, and it +recreated .axiomcode (its refresh.log, refresh.json) while the user was deleting it, so `rm -rf .axiomcode` failed with +"Directory not empty". So every path that writes there without being asked to (the refresher, the hooks, the MCP server +and its timer, a query's background build) asks orphaned() first, and an orphaned install writes nothing and starts +nothing: the MCP server stops, a hook and a query say so instead. + +Its own module, with nothing but os and json, because every hook asks it before anything else: importing ax_fresh for it +cost a hook 20-100 ms on every tool call.""" +import json, os + +H = os.path.dirname(os.path.abspath(__file__)) +ORPHAN_MARK = '.orphaned_at' + +def install_dir(scripts=None): + """the plugin directory a scripts directory belongs to (/skills/axiomcode/scripts), these scripts' by default""" + return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(scripts or H)))) + +def _install_version(root): + try: return str(json.load(open(os.path.join(root, '.claude-plugin', 'plugin.json'), encoding='utf-8')).get('version') or '') + except (OSError, ValueError, AttributeError): pass + b = os.path.basename(os.path.normpath(root)) # a versioned plugin cache: /// + return b if all(x.isdigit() for x in b.split('.')) else '' + +def orphaned(root=None): + """'' while the install is current, else one line saying this is an orphaned older axiomcode and the agent session + is to be restarted. root: the plugin directory to judge (the MCP server passes the one it resolved); by default the + one these scripts belong to. Orphaned: the plugin directory carries ORPHAN_MARK, or its scripts are gone""" + if root: + root = os.path.abspath(root) + gone = not os.path.isfile(os.path.join(root, 'skills', 'axiomcode', 'scripts', 'ax_fresh.py')) + else: + # these scripts' own file is what is checked for "gone": an engine that lays them out differently is never orphaned + root = install_dir(); gone = not os.path.isfile(os.path.join(H, 'ax_fresh.py')) + if os.path.lexists(os.path.join(root, ORPHAN_MARK)): + why = f"{root} is marked {ORPHAN_MARK}: a newer version was installed in its place" + elif gone: why = f"its install directory {root} is gone: a newer version was installed in its place" + else: return '' + v = _install_version(root) + return (f"this is an orphaned older axiomcode{' ' + v if v else ''} ({why}), still running in an agent session started " + "before the upgrade; it writes nothing into the repository's .axiomcode and rebuilds nothing. Restart the agent " + "session so that only the installed axiomcode runs") diff --git a/tests/README.md b/tests/README.md index 06150d2f..69540784 100644 --- a/tests/README.md +++ b/tests/README.md @@ -68,6 +68,11 @@ One check needs no graph and is its own script: and the refresh is expected within the budget, never on a rules compile; --fresh waits and MCP takes fresh=true; the file table prunes exactly what each parser skips, so an edit under out/ or build/ is seen (#1594, #1595; no engine) + python3 tests/two_versions.py two axiomcode versions over one repository: an older one's graph is rebuilt + once, not again each time the older one rebuilds it back, and every answer says + two versions are running and to restart the agent session; an install marked + .orphaned_at, or whose files are gone, writes nothing under .axiomcode from its + hooks, timer, refresher, query or MCP server (no engine) python3 tests/refresh_races.py what a refresh after an edit can leave behind, each with a control: a broken graph pointer is repaired and keeps the baseline; a query mid-build waits for it and starts no build; a failed build never leaves the pointer dangling; a failed diff --git a/tests/two_versions.py b/tests/two_versions.py new file mode 100644 index 00000000..f36c86f5 --- /dev/null +++ b/tests/two_versions.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +"""tests/two_versions.py: two axiomcode versions over one repository rebuild its graph once, not on every turn, say that +two versions are running, and an orphaned older install writes nothing into .axiomcode. + +An agent session started before an engine or plugin upgrade keeps the old version's MCP server and hooks running beside +the new ones. The old one rebuilt the graph with its engine, the new one rebuilt it back, and so on, a full rebuild on +every turn; the answers said "stale graph"; and the old one's refresher recreated .axiomcode while the user deleted it. + + turns this axiomcode rebuilds a graph an older one built, once; when that older one rebuilds it again afterwards, + this one does not rebuild it in the background again (refresher, hook, query), and the status, the query's + note and the hook all say two versions are running and to restart the agent session. Controls: an older + graph met for the first time is rebuilt; a rebuild that failed (the older graph not rebuilt since) is retried; + `index` (uptodate) still rebuilds a contested graph, asked for by name + newer an older axiomcode over a graph a newer one built keeps it, and says the two-versions case (note and the + `newer` command), not only "built by a newer axiomcode" + orphan an install whose plugin directory carries .orphaned_at, or whose files are gone, writes nothing under + .axiomcode from its refresh hook (session start, an edit), its other hooks, its timer (hook_kick), its + refresher, a query, or its MCP server, and says it is an orphaned older version to restart; the MCP server + refuses to start. Control: the same copy without the mark does write (the refresher starts on an edit) + +No engine: a stand-in engine directory, and a stand-in build that records what built the graph, as freshness.py does. + + python3 tests/two_versions.py [-v] +""" +import json, os, shutil, subprocess, sys, tempfile, time + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +import freshness # its stand-in engine and repository +from freshness import ax_fresh, fake_engine, fake_repo, check, RESULTS, SCRIPTS, ROOT, DRIVER + +PLUGIN = os.path.join(ROOT, 'plugins', 'axiomcode') + +# the stand-in build: records that it ran, and writes the file table as this axiomcode's build would (built_by, built) +BUILD = r""" +import json, os, sys, time +sys.path.insert(0, os.environ['TV_SCRIPTS']); import ax_fresh +repo = sys.argv[2] +open(os.path.join(repo, 'builds'), 'a').write('x\n') +tp = ax_fresh.table_path(repo); t = json.load(open(tp)) +t.update(built=time.time(), files=ax_fresh.snapshot(repo, 'python', repo), built_by=ax_fresh.built_by(ax_fresh.current_engine(repo), 'python')) +json.dump(t, open(tp, 'w')) +""" + + +def tree(d): + """every path under d with its size and mtime: what a process wrote there""" + out = {} + for root, dirs, files in os.walk(d): + for f in dirs + files: + p = os.path.join(root, f) + try: st = os.lstat(p); out[os.path.relpath(p, d)] = (st.st_size, st.st_mtime_ns) + except OSError: pass + return out + + +def diff(a, b): + return sorted(k for k in set(a) | set(b) if a.get(k) != b.get(k)) + + +def turns_checks(work, env): + fb = os.path.join(work, 'build.py'); open(fb, 'w').write(BUILD) + runner = os.path.join(work, 'runner.sh'); open(runner, 'w').write(f'#!/bin/sh\nexec "{sys.executable}" "{fb}" "$@"\n'); os.chmod(runner, 0o755) + wenv = dict(env, AXIOMCODE_BASH=runner, TV_SCRIPTS=SCRIPTS) + + def builds(repo): + try: return len(open(os.path.join(repo, 'builds')).read().split()) + except OSError: return 0 + + def worker(repo): + subprocess.run([sys.executable, os.path.join(SCRIPTS, 'ax_fresh.py'), 'worker', repo], env=wenv, capture_output=True, text=True, timeout=60) + return builds(repo) + + other = dict(engine_version='0.9.0', engine_hash='0' * 40, engine_stat='0' * 40) + + def older_turn(repo): + """the older axiomcode rebuilds the graph with its own engine: the table records it, built now""" + tp = ax_fresh.table_path(repo); t = json.load(open(tp)) + t['built_by'].update(other); t['built'] = time.time(); json.dump(t, open(tp, 'w')) + + repo = fake_repo(work, 'turns'); os.remove(os.path.join(repo, '.axiomcode/refresh.json')) + older_turn(repo) + s = ax_fresh.status(repo) + check("turns: control: an older axiomcode's graph met for the first time is stale and not contested", + s.get('engine', '').startswith('graph built by an older axiomcode') and not s.get('contested'), s) + n1 = worker(repo) + check("turns: control: the refresher rebuilds an older axiomcode's graph once", n1 == 1, n1) + check("turns: after this one's rebuild the graph is current", ax_fresh.status(repo).get('state') == 'fresh', ax_fresh.status(repo)) + rounds = [] + for i in range(3): + time.sleep(0.02); older_turn(repo) # the older one, still running, rebuilds it back + rounds.append(worker(repo)) + check(f"turns: the older one rebuilding it back three times costs no further rebuild here (builds after each turn: {rounds})", + rounds == [1, 1, 1], rounds) + s = ax_fresh.status(repo) + two = s.get('contested', '') + check("turns: the status names the two-versions case: both versions, and restart the agent session", + 'two axiomcode versions are running' in two and 'axiomcode 0.9.0' in two and 'axiomcode 1.0.0' in two + and 'restart the agent session' in two and ax_fresh.read_state(repo).get('state') == 'contested', s) + # a query answers from it, starts nothing, waits for nothing, and its note is the two-versions line, not a stale-graph one + driver = os.path.join(work, 'driver.py'); open(driver, 'w').write(DRIVER.replace("ax_fresh.kick = lambda *a, **k: True", + "ax_fresh.kick = lambda *a, **k: open(os.path.join(sys.argv[2], 'kicked'), 'w').close() or True")) + t0 = time.time() + r = subprocess.run([sys.executable, driver, SCRIPTS, repo, 'impact', '--', sys.executable, '-c', "print('rows')"], + capture_output=True, text=True, env=dict(env, AXIOMCODE_FRESH_WAIT='30')) + took = time.time() - t0 + check(f"turns: a query answers at once ({took:.1f}s), starts no rebuild, and says two versions are running rather than 'rebuilding'", + r.stdout.strip() == 'rows' and 'two axiomcode versions are running' in r.stderr and 'rebuilding in the background' not in r.stderr + and 'queued' not in r.stderr and 'background rebuild' not in r.stdout and not os.path.exists(os.path.join(repo, 'kicked')) and took < 10, (r.stdout, r.stderr)) + h = ax_fresh.hook_kick(repo, 'the PostToolUse hook', session='s9', event='UserPromptSubmit') + check("turns: the hook says the two-versions case once", 'two axiomcode versions are running' in h + and ax_fresh.hook_kick(repo, 'the PostToolUse hook', session='s9', event='UserPromptSubmit') == '', h) + time.sleep(0.02); older_turn(repo) # the older one's graph, whichever way the turns above went + u = subprocess.run([sys.executable, os.path.join(SCRIPTS, 'ax_fresh.py'), 'uptodate', repo, 'python', '', ''], capture_output=True, text=True, env=env) + check("turns: control: `index` (uptodate) still rebuilds a contested graph, asked for by name", u.returncode == 1 and 'rebuilding' in u.stdout, u.stdout) + + # control: a rebuild that FAILED leaves the older graph as it was (not rebuilt since): that is no contest, it is retried + repo = fake_repo(work, 'failed'); os.remove(os.path.join(repo, '.axiomcode/refresh.json')) + older_turn(repo); ax_fresh.displace(repo, ax_fresh.load_table(repo)) + check("turns: control: an older graph not rebuilt since this one displaced it (a failed rebuild) is not contested, and is rebuilt", + not ax_fresh.status(repo).get('contested') and worker(repo) == 1, ax_fresh.status(repo)) + + +def newer_checks(work, env): + repo = fake_repo(work, 'newer'); os.remove(os.path.join(repo, '.axiomcode/refresh.json')) + tp = ax_fresh.table_path(repo); t = json.load(open(tp)); t['built_by'].update(engine_version='1.0.1'); json.dump(t, open(tp, 'w')) + s = ax_fresh.status(repo) + note = ax_fresh.note(s) + check("newer: an older axiomcode over a newer one's graph says two versions are running and to restart, and that it does not rebuild", + s.get('newer') and 'engine 1.0.1, this one is 1.0.0' in note and 'two axiomcode versions are running' in note + and 'restart the agent session' in note and 'does not rebuild' in note, note) + n = subprocess.run([sys.executable, os.path.join(SCRIPTS, 'ax_fresh.py'), 'newer', repo], capture_output=True, text=True, env=env) + check("newer: `index` is told the two-versions case too", n.returncode == 0 and 'two axiomcode versions are running' in n.stdout, n.stdout) + + +def orphan_checks(work, env): + fb = os.path.join(work, 'build-orphan.py'); open(fb, 'w').write("import os, sys; open(os.path.join(sys.argv[2], 'builds'), 'a').write('x\\n')\n") + runner = os.path.join(work, 'runner-orphan.sh'); open(runner, 'w').write(f'#!/bin/sh\nexec "{sys.executable}" "{fb}" "$@"\n'); os.chmod(runner, 0o755) + cfg = os.path.join(work, 'agent-config'); os.makedirs(cfg) + # TMPDIR: the hooks remember a session's repositories there, and a session-start hook refreshes them too + oenv = dict(env, AXIOMCODE_BASH=runner, AXIOMCODE_REFRESH_DEBOUNCE='0.05', CLAUDE_CONFIG_DIR=cfg, TMPDIR=os.path.join(work, 'tmp')) + os.makedirs(oenv['TMPDIR']) + for k in ('AXIOMCODE_PLUGIN_ROOT', 'AXIOMCODE_NO_REFRESH'): oenv.pop(k, None) + + # this checkout's plugin version, which the orphan message names (it moves with every release) + VERSION = json.load(open(os.path.join(PLUGIN, '.claude-plugin', 'plugin.json'), encoding='utf-8'))['version'] + + def install(name, mark): + """a copy of this plugin in a versioned plugin cache: ///""" + d = os.path.join(work, 'cache', name, 'axiomcode', VERSION) + shutil.copytree(PLUGIN, d, ignore=shutil.ignore_patterns('__pycache__')) + if mark: open(os.path.join(d, '.orphaned_at'), 'w').write('1791880805485\n') + return d + + def edited_repo(name): + repo = fake_repo(work, name); os.remove(os.path.join(repo, '.axiomcode/refresh.json')) + open(os.path.join(repo, 'shop/api.py'), 'a').write('\ndef audit(items):\n return total(items)\n') + return repo + + def hook(plugin, repo, event, **extra): + ev = dict(hook_event_name=event, cwd=repo, session_id='two-versions-' + os.path.basename(repo), **extra) + return subprocess.run([sys.executable, os.path.join(plugin, 'hooks', 'refresh.py')], input=json.dumps(ev), capture_output=True, text=True, env=oenv, timeout=30) + + def everything(plugin, repo): + """every path by which an install writes into .axiomcode without being asked to; returns what the hooks printed""" + said = hook(plugin, repo, 'SessionStart').stdout + hook(plugin, repo, 'PostToolUse', tool_name='Edit', tool_input=dict(file_path=os.path.join(repo, 'shop/api.py'))) + for h in ('changes.py', 'orient.py', 'assist.py'): + subprocess.run([sys.executable, os.path.join(plugin, 'hooks', h)], input=json.dumps(dict(hook_event_name='UserPromptSubmit', cwd=repo, session_id='two-versions-' + os.path.basename(repo), + prompt='how does run reach total')), capture_output=True, text=True, env=oenv, timeout=60) + scripts = os.path.join(plugin, 'skills', 'axiomcode', 'scripts') + code = ("import sys; sys.path.insert(0, sys.argv[1]); import ax_fresh; ax_fresh.hook_kick(sys.argv[2], 'the timer'); " + "ax_fresh.kick(sys.argv[2]); ax_fresh.worker(sys.argv[2])") + subprocess.run([sys.executable, '-c', code, scripts, repo], capture_output=True, text=True, env=oenv, timeout=60) + q = subprocess.run([sys.executable, os.path.join(scripts, 'ax_fresh.py'), 'query', repo, 'impact', '--', sys.executable, '-c', "print('rows')"], + capture_output=True, text=True, env=oenv, timeout=60) + time.sleep(1.0) # a detached refresher, had one been started, has written by now + return said, q.stdout + + # control first: the same copy, not orphaned, does write on an edit (else "wrote nothing" below would prove nothing) + live = install('live', mark=False); repo = edited_repo('orphan-control') + before = tree(os.path.join(repo, '.axiomcode')); everything(live, repo); time.sleep(1.5) + wrote = diff(before, tree(os.path.join(repo, '.axiomcode'))) + check("orphan: control: the same install, not orphaned, writes under .axiomcode after an edit (the refresher starts)", + 'refresh.log' in wrote and 'refresh.json' in wrote, wrote) + + plugin = install('marked', mark=True); repo = edited_repo('orphan-marked') + before = tree(os.path.join(repo, '.axiomcode')); said, q = everything(plugin, repo) + wrote = diff(before, tree(os.path.join(repo, '.axiomcode'))) + check("orphan: an install marked .orphaned_at writes nothing under .axiomcode (hooks, timer, refresher, query)", wrote == [] and not + os.path.exists(os.path.join(repo, 'builds')), wrote) + check("orphan: its session-start hook says it is an orphaned older version and to restart the agent session", + f'orphaned older axiomcode {VERSION}' in said and 'Restart the agent session' in said, said) + check("orphan: its query answers with the restart, not from a build of its own", 'orphaned older axiomcode' in q and 'rows' not in q, q) + # the MCP server: a tool call answers with the restart and writes nothing; started anew it refuses to start + code = ("import os, sys; sys.path.insert(0, os.path.join(sys.argv[1], 'mcp')); import server; " + "print(server.run(['impact', 'run', sys.argv[2]]))") + before = tree(os.path.join(repo, '.axiomcode')) + r = subprocess.run([sys.executable, '-c', code, plugin, repo], capture_output=True, text=True, cwd=repo, timeout=60, + env=dict(oenv, AXIOMCODE_PLUGIN_ROOT=plugin)) + check("orphan: its MCP server's tool call answers that it is an orphaned older version, running nothing", + 'orphaned older axiomcode' in r.stdout and 'Restart the agent session' in r.stdout and diff(before, tree(os.path.join(repo, '.axiomcode'))) == [], + (r.stdout, r.stderr[-400:])) + r = subprocess.run([sys.executable, os.path.join(plugin, 'mcp', 'server.py')], capture_output=True, text=True, cwd=repo, timeout=60, + stdin=subprocess.DEVNULL, env=dict(oenv, AXIOMCODE_PLUGIN_ROOT=plugin)) + check("orphan: its MCP server, started, says so and does not start, writing nothing", + r.returncode != 0 and 'orphaned older axiomcode' in r.stderr and 'Not starting' in r.stderr + and diff(before, tree(os.path.join(repo, '.axiomcode'))) == [], (r.returncode, r.stderr[-400:])) + + # the install directory deleted under a running process (the plugin manager's later step): nothing is written either + plugin = install('gone', mark=False); repo = edited_repo('orphan-gone') + scripts = os.path.join(plugin, 'skills', 'axiomcode', 'scripts') + before = tree(os.path.join(repo, '.axiomcode')) + code = ("import shutil, sys; sys.path.insert(0, sys.argv[1]); import ax_fresh; shutil.rmtree(sys.argv[3]); " + "print(ax_fresh.orphaned()); ax_fresh.hook_kick(sys.argv[2], 'the timer'); ax_fresh.kick(sys.argv[2]); ax_fresh.worker(sys.argv[2])") + r = subprocess.run([sys.executable, '-c', code, scripts, repo, plugin], capture_output=True, text=True, env=oenv, timeout=60) + time.sleep(1.0) + wrote = diff(before, tree(os.path.join(repo, '.axiomcode'))) + check("orphan: a running process whose install directory was deleted says so and writes nothing under .axiomcode", + 'is gone' in r.stdout and 'Restart the agent session' in r.stdout and wrote == [], (r.stdout, r.stderr[-400:], wrote)) + check("orphan: control: this checkout (no mark, files present) is not orphaned", ax_fresh.orphaned() == '', ax_fresh.orphaned()) + + +def main(): + work = tempfile.mkdtemp(prefix='axiomcode-two-versions-'); saved = os.environ.get('AXIOMCODE_ENGINE') + try: + e1 = fake_engine(os.path.join(work, 'e1'), version='1.0.0'); os.environ['AXIOMCODE_ENGINE'] = e1 + env = {k: v for k, v in os.environ.items() if k not in ('AXIOMCODE_NO_REFRESH', 'AXIOMCODE_FRESH', 'AXIOMCODE_GRAPH')} + env.update(AXIOMCODE_REFRESH_DEBOUNCE='0.05', AXIOMCODE_REFRESH_SLOTS=os.path.join(work, 'slots')) + for part in (turns_checks, newer_checks, orphan_checks): + try: part(work, env) + except Exception as e: # a missing helper is a failure of that part, not of the run + check(f"{part.__name__}: ran to the end", False, f"{type(e).__name__}: {e}") + finally: + if saved is None: os.environ.pop('AXIOMCODE_ENGINE', None) + else: os.environ['AXIOMCODE_ENGINE'] = saved + shutil.rmtree(work, ignore_errors=True) + bad = [n for n, ok in RESULTS if not ok] + print(f"\n{len(RESULTS) - len(bad)} passed, {len(bad)} failed") + return 1 if bad or not RESULTS else 0 + + +if __name__ == '__main__': + sys.exit(main())