From da61e3f130f6b186e7cb93c889d4a606a8cf7e63 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:11:09 -0700 Subject: [PATCH] plugin: two axiomcode versions over one graph rebuild once, say so, and an orphan writes nothing 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 and the new one rebuilt it back, a full rebuild on every turn (reproduced: 3 rebuilds in 3 turns against a 0.1.9 build). The answers only said the graph was stale. And the old process recreated .axiomcode (refresh.log, refresh.json) while the user deleted it. - ax_orphan.orphaned(): one check for a plugin directory marked .orphaned_at, or one whose scripts are gone. When it fires, the refresher, hooks, timer, query, relink and write_state write nothing. The MCP server answers with "restart the agent session", stops its timer and refuses to start. - ax_fresh.displace/contested: a build that replaces another axiomcode's graph records who built that graph. If the same builder rebuilds it again later, this version stops rebuilding it in the background and says two versions are running. `axiomcode index` still rebuilds it. - The newer-graph note and the `newer` command now name the two-versions case and say to restart the session. - README: restart the agent session after upgrading the engine or the plugin. Test: tests/two_versions.py (fails 14 of 19 on the base, passes 19 of 19 now). Refs #1835 --- README.md | 3 + plugins/axiomcode/hooks/_host.py | 16 +- plugins/axiomcode/hooks/refresh.py | 2 +- plugins/axiomcode/mcp/server.py | 22 ++ .../skills/axiomcode/scripts/ax_fresh.py | 104 +++++++- .../skills/axiomcode/scripts/ax_orphan.py | 46 ++++ tests/README.md | 5 + tests/two_versions.py | 244 ++++++++++++++++++ 8 files changed, 428 insertions(+), 14 deletions(-) create mode 100644 plugins/axiomcode/skills/axiomcode/scripts/ax_orphan.py create mode 100644 tests/two_versions.py diff --git a/README.md b/README.md index ba0d1d453..eab92e7f4 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 e133112a2..5ae1b7335 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 e5798d22d..87bf297e5 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 27c4c3574..b1ebc5e62 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 e1fc01047..1958b7e3a 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 000000000..b9148d809 --- /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 06150d2fd..69540784a 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 000000000..f36c86f59 --- /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())