Skip to content

Commit 0acc0ae

Browse files
Query latency: do once what every query repeated; never hold a call silently for a first build
Measured per phase on a 34k-line React/TypeScript app and on this repository, idle and with 9 of 10 cores busy. On a fresh graph every verb answers in 0.2-1.1 s; the time went to work repeated on every call with the same result: - impact opened every non-source file in the tree on every query (twice) to look for the changed name: 0.6 s per query here, and a dev server's build folder (.next source maps) added ~1,500 files and 190 noise rows. The scan is now cached per graph in out/dl/nonsource.sqlite: files are statted, opened only when their size or mtime moved, and searched only when their words include a name asked for. Build output and coverage folders (ax_fresh.PRUNE_ALL) are skipped. Warmed by `index`. - the impact rules ran again for a question already asked (page 2, a repeat, changed --impact then test-impact). The solve is now keyed by the rules, the graph export and every per-query fact, and read back on a repeat. - the freshness check walked the tree once per language: one walk now (0.165 s -> 0.033 s on five languages), the same file set. - bin/axiomcode started a second bash, a sed, a tr, two greps and dirname per level to recognise a verb and find its root; it reads the frontend's dispatch table in-process now. - the node launchers (CLI, MCP, every hook) started Python to ask where it lives on every call; the answer is remembered, keyed by PATH, and probed again when a candidate resolves elsewhere. - a query with no graph blocked silently for the whole first build, and a second one queued another build behind it; under the MCP server past 900 s it failed with a bare tool error. Now the MCP server's queries start the build in the background and answer within AXIOMCODE_BUILD_WAIT seconds with the stage it is at; a query during a build reports on it; the shell prints the stage as it moves; a timed-out MCP call answers in words. Answers are unchanged: 41 queries (impact, path, context, changed, changed --impact, test-impact) on both repositories compared byte for byte before/after, cold and warm caches. tests/latency.py pins each change against the slow path it replaces. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com>
1 parent 6ace630 commit 0acc0ae

10 files changed

Lines changed: 732 additions & 60 deletions

File tree

‎bin/axiomcode‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,13 @@ if [ -L "$self" ]; then
5757
fi
5858
self="$r"
5959
fi
60-
ROOT="$(d="$(cd "$(dirname "$self")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")"
60+
# NO PROGRAM IS STARTED TO FIND IT: the parent of a path is taken by parameter expansion, not `dirname`. Every query goes
61+
# through here, and under Git Bash on Windows each program started is a fork emulation that costs tens of milliseconds
62+
# idle and far more on a busy machine; the walk used to start one per level, the verb check below four more.
63+
case "$self" in */*) d="${self%/*}";; *) d=.;; esac
64+
d="$(cd "${d:-/}" && pwd)"
65+
while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="${d%/*}"; d="${d:-/}"; done
66+
ROOT="$d"
6167
# No marker found means every path built from ROOT is nonsense; say so here rather than fail
6268
# later on a path the user cannot interpret.
6369
[ -d "$ROOT/graph" ] || { echo "axiomcode: cannot locate the package root from $0 (resolved to $self)" >&2; exit 2; }
@@ -76,8 +82,20 @@ QUERY="$ROOT/plugins/axiomcode/skills/axiomcode/scripts/axiomcode"
7682
# `tests` is the frontend's alias for test-impact; `test` is THIS command's engine suite. One letter
7783
# apart, entirely different jobs, and putting both on one command is a trap — so the alias is not
7884
# routed here, and the name is refused below with both readings spelled out.
79-
query_verbs(){ [ -f "$QUERY" ] && bash "$QUERY" --verbs 2>/dev/null | grep -vx tests || true; }
80-
is_query_verb(){ query_verbs | grep -qx -- "$1"; }
85+
# The list is read from the frontend's own dispatch table, the lines ` <verb>[|<alias>]) exec …` that its `--verbs`
86+
# prints, so the two entry points still cannot drift -- but read here, in this shell, instead of by a second bash, a
87+
# sed, a tr and two greps on every query.
88+
QVERBS=()
89+
load_verbs(){
90+
[ ${#QVERBS[@]} -gt 0 ] && return 0; [ -f "$QUERY" ] || return 0
91+
local l v x re='^ ([a-z|-]*)\) *exec '
92+
while IFS= read -r l || [ -n "$l" ]; do
93+
[[ $l =~ $re ]] || continue; v="${BASH_REMATCH[1]}"
94+
for x in ${v//|/ }; do [ "$x" = tests ] || QVERBS+=("$x"); done
95+
done < "$QUERY"
96+
}
97+
query_verbs(){ load_verbs; [ ${#QVERBS[@]} -gt 0 ] && printf '%s\n' "${QVERBS[@]}"; return 0; }
98+
is_query_verb(){ load_verbs; local x; for x in ${QVERBS[@]+"${QVERBS[@]}"}; do [ "$x" = "$1" ] && return 0; done; return 1; }
8199
usage(){
82100
awk 'NR > 2 && /^# ─/ {exit} NR > 2' "$0" | sed 's/^# \{0,1\}//'
83101
if [ -f "$QUERY" ]; then

‎plugins/axiomcode/mcp/find-python.js‎

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
'use strict';
1515
const { spawnSync } = require('child_process');
1616
const { which } = require('./which.js');
17+
const crypto = require('crypto');
18+
const fs = require('fs');
19+
const os = require('os');
1720
const path = require('path');
1821

1922
const SHIM = path.join(__dirname, '..', 'skills', 'axiomcode', 'scripts', 'pyshim');
@@ -24,14 +27,67 @@ function candidates() {
2427
}
2528

2629
// { cmd, exe } or { error }: cmd is how the candidate was named, exe the interpreter file it runs.
30+
// THE PROBE IS REMEMBERED. It starts an interpreter, and it ran on every `axiomcode` command and on every hook, which fire
31+
// on every tool call an agent makes: one Python start each time, before the work, and on a busy Windows machine that is
32+
// the slowest process there is to start. Its answer depends only on what each candidate name resolves to on PATH, so it is
33+
// kept in the temp directory under a key of PATH and AXIOMCODE_PYTHON, with the file every candidate it tried resolved to
34+
// (and that file's mtime). A later call re-resolves those names -- a stat per directory, no process -- and probes again
35+
// only when one of them resolves elsewhere, or the file changed: a Python installed, removed or upgraded.
36+
// AXIOMCODE_NO_PYTHON_CACHE=1 probes every time.
37+
function onPath(name) {
38+
if (process.platform === 'win32' || path.isAbsolute(name) || /[\\/]/.test(name)) {
39+
const p = process.platform === 'win32' ? which(name) : name;
40+
if (!p) return null;
41+
try { return [p, fs.statSync(p).mtimeMs]; } catch { return null; }
42+
}
43+
for (const d of (process.env.PATH || '').split(path.delimiter)) {
44+
if (!d || !path.isAbsolute(d)) continue;
45+
const p = path.join(d, name);
46+
try { const st = fs.statSync(p); if (st.isFile()) return [p, st.mtimeMs]; } catch { /* not here */ }
47+
}
48+
return null;
49+
}
50+
51+
function cacheFile() {
52+
const key = crypto.createHash('sha1').update([process.platform, process.arch, process.env.PATH || process.env.Path || '',
53+
process.env.AXIOMCODE_PYTHON || ''].join('\0')).digest('hex').slice(0, 16);
54+
return path.join(os.tmpdir(), `axiomcode-python-${key}.json`);
55+
}
56+
57+
function cached() {
58+
if (process.env.AXIOMCODE_NO_PYTHON_CACHE) return null;
59+
try {
60+
const c = JSON.parse(fs.readFileSync(cacheFile(), 'utf8'));
61+
for (const [name, was] of c.seen) {
62+
const now = onPath(name);
63+
if (JSON.stringify(now) !== JSON.stringify(was)) return null;
64+
}
65+
if (!fs.statSync(c.exe).isFile()) return null;
66+
return { cmd: c.cmd, exe: c.exe };
67+
} catch { return null; }
68+
}
69+
70+
function remember(found, seen) {
71+
if (process.env.AXIOMCODE_NO_PYTHON_CACHE) return;
72+
try {
73+
const f = cacheFile(), tmp = `${f}.${process.pid}`;
74+
fs.writeFileSync(tmp, JSON.stringify({ cmd: found.cmd, exe: found.exe, seen }));
75+
fs.renameSync(tmp, f);
76+
} catch { /* a read-only temp directory only costs the probe next time */ }
77+
}
78+
2779
function findPython() {
80+
const hit = cached();
81+
if (hit) return hit;
82+
const seen = [];
2883
for (const cmd of candidates()) {
84+
seen.push([cmd[0], onPath(cmd[0])]);
2985
const exe0 = which(cmd[0]); // PATH only: never a python.exe in the current directory
3086
if (!exe0) continue;
3187
const r = spawnSync(exe0, [...cmd.slice(1), '-c', 'import sys; print(sys.executable)'],
3288
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, timeout: 15000 });
3389
const exe = r.status === 0 && String(r.stdout).trim();
34-
if (exe) return { cmd, exe };
90+
if (exe) { const found = { cmd, exe }; remember(found, seen); return found; }
3591
}
3692
return { error: 'axiomcode needs Python 3, and no python3, python' + (process.platform === 'win32' ? ' or py -3' : '') +
3793
' on PATH runs.\n • install it (https://www.python.org/downloads/), or\n' +

‎plugins/axiomcode/mcp/server.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,23 @@ def _timer(interval):
4141
def run(args, cwd=None, timeout=900):
4242
for a in args[1:]:
4343
if os.path.isdir(a) and os.path.isdir(os.path.join(a, '.axiomcode')): SEEN.add(os.path.realpath(a))
44+
# A QUERY ON A REPOSITORY WITH NO GRAPH YET does not hold the call for the length of a first build (minutes; past this
45+
# server's timeout it came back as a bare "Error executing tool"): the build is started in the background and the
46+
# answer says what stage it is at (ax_contract.ensure_graph). `index` is asked for a build and still waits for it.
47+
env = dict(os.environ, AXIOMCODE_BUILD_NOWAIT='1') if args and args[0] != 'index' else None
4448
try:
45-
r = subprocess.run([BASH, AX, *args], cwd=cwd or None, capture_output=True, text=True, timeout=timeout)
49+
r = subprocess.run([BASH, AX, *args], cwd=cwd or None, capture_output=True, text=True, timeout=timeout, env=env)
4650
except OSError as e:
4751
return (f"axiomcode could not start bash ({BASH}): {e}. On Windows it needs the bash that comes with "
4852
"Git for Windows; install it, or set AXIOMCODE_BASH to its bin\\bash.exe.")
53+
except subprocess.TimeoutExpired:
54+
repo = next((a for a in reversed(args[1:]) if os.path.isdir(a)), cwd or os.getcwd())
55+
try:
56+
sys.path.insert(0, os.path.dirname(AX)); import ax_contract, ax_fresh
57+
if ax_fresh.building(os.path.realpath(repo)): return ax_contract.building_note(os.path.realpath(repo))
58+
except Exception: pass
59+
return (f"axiomcode {args[0] if args else ''} did not answer within {timeout} s. Nothing was changed; "
60+
f"see {os.path.join(repo, '.axiomcode', 'build.log')} if a build was running, and ask again.")
4961
out = (r.stdout or '') + (('\n' + r.stderr.strip()) if r.returncode and r.stderr.strip() else '')
5062
# an answer given from a graph that predates some edit says so, and names the files (#1305)
5163
if not r.returncode: out += ''.join('\n' + l for l in (r.stderr or '').splitlines() if l.startswith('graph refresh:'))

‎plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py‎

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
5. STATE THE BOUND. Say what the answer cannot see — the relations this graph does not encode — so a
2424
partial list is not read as a complete one.
2525
"""
26-
import collections, difflib, os, re, subprocess, sys
26+
import collections, difflib, os, re, subprocess, sys, time
2727

2828
SPLIT = re.compile(r'[^A-Za-z0-9]+')
2929
CAMEL = re.compile(r'[A-Z]+(?![a-z])|[A-Z][a-z0-9]*|[a-z0-9]+')
@@ -384,13 +384,87 @@ def ensure_graph(repo, db):
384384
if not os.environ.get('AXIOMCODE_AUTOBUILD') or os.environ.get('AXIOMCODE_GRAPH'): return False
385385
build = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'axiomcode-build')
386386
if not os.path.exists(build): return False
387+
import ax_fresh
388+
bash = os.environ.get('AXIOMCODE_BASH') or 'bash'
389+
# NEVER A SILENT WAIT. A first build takes minutes (a whole-program solve, and on a machine that has never built this
390+
# language, a one-time compile of its rules), and it used to run with nothing said after its first line, so a caller
391+
# could not tell a build from a hang. Under the MCP server (AXIOMCODE_BUILD_NOWAIT) the build is started in the
392+
# background and the call answers within AXIOMCODE_BUILD_WAIT seconds either way: with the graph when it was quick,
393+
# else with the stage the build is at, so the agent is told to ask again rather than left waiting past the host's
394+
# timeout. From a shell the call waits for it, printing the stage as it moves. A call that finds a build already
395+
# running (another query's, or an `axiomcode index`) reports on that one rather than queueing a second behind it.
396+
nowait = bool(os.environ.get('AXIOMCODE_BUILD_NOWAIT'))
397+
if ax_fresh.building(repo):
398+
if nowait: _BUILD_NOTE.append(building_note(repo)); return False
399+
print(f"a graph build is already running for {repo}; waiting for it …", file=sys.stderr)
400+
_follow(repo, lambda: ax_fresh.building(repo))
401+
return os.path.exists(db)
387402
print(f"no graph for {repo} yet — building one (this is the only slow call; later ones read it) …", file=sys.stderr)
388-
r = subprocess.run([os.environ.get('AXIOMCODE_BASH') or 'bash', build, repo])
389-
return r.returncode == 0 and os.path.exists(db)
403+
if nowait:
404+
os.makedirs(os.path.join(repo, '.axiomcode'), exist_ok=True)
405+
log = open(os.path.join(repo, '.axiomcode', 'first-build.log'), 'w')
406+
kw = dict(start_new_session=True) if os.name != 'nt' else dict(creationflags=0x00000008 | 0x00000200) # DETACHED | NEW_GROUP, as ax_fresh.kick
407+
p = subprocess.Popen([bash, build, repo], stdin=subprocess.DEVNULL, stdout=log, stderr=log, close_fds=True, **kw)
408+
# until the build holds its lock a second query would see no build and start another, so that much is always waited
409+
up = time.time() + 10
410+
while p.poll() is None and not ax_fresh.building(repo) and time.time() < up: time.sleep(0.05)
411+
end = time.time() + float(os.environ.get('AXIOMCODE_BUILD_WAIT') or 60)
412+
while p.poll() is None and time.time() < end: time.sleep(0.5)
413+
if p.poll() is None: _BUILD_NOTE.append(building_note(repo)); return False
414+
return p.returncode == 0 and os.path.exists(db)
415+
p = subprocess.Popen([bash, build, repo])
416+
_follow(repo, lambda: p.poll() is None)
417+
return p.returncode == 0 and os.path.exists(db)
418+
419+
420+
_BUILD_NOTE = []
421+
TICK = 20.0
422+
423+
424+
def build_stage(repo):
425+
"""(stage, done, of): the last `▶` step the running build's log names, and how many of its languages it has
426+
started solving, from .axiomcode/build.log"""
427+
stage, langs, solving = '', [], 0
428+
try: text = open(os.path.join(repo, '.axiomcode', 'build.log'), errors='replace').read()
429+
except OSError: text = ''
430+
for l in text.splitlines():
431+
if not l.startswith('\u25b6 '): continue
432+
stage = l[2:].strip()
433+
if stage.startswith('languages:'): langs = stage.split(':', 1)[1].split()
434+
elif stage.startswith('solving '): solving += 1
435+
return stage, solving, len(langs)
436+
437+
438+
def building_note(repo):
439+
"""one line: a graph is being built, what it is doing and for how long, and what to do meanwhile"""
440+
try: took = time.time() - os.path.getmtime(os.path.join(repo, '.axiomcode', 'build.lock'))
441+
except OSError: took = 0
442+
stage, n, of = build_stage(repo)
443+
where = (f"language {n} of {of}, " if of and n else '') + (stage or 'starting')
444+
if 'compiling souffle program' in stage:
445+
where += ' — a one-time compile of this language\'s rules on this machine, a few minutes; later builds reuse it'
446+
return (f"the graph for {repo} is being built ({where}; {int(took // 60)}m{int(took % 60):02d}s so far). "
447+
f"Nothing else is needed: ask again in a minute or two, or read the code directly meanwhile. "
448+
f"Progress: {os.path.join(repo, '.axiomcode', 'build.log')}")
449+
450+
451+
def _follow(repo, running):
452+
"""wait while running(), printing the build's stage to stderr whenever it moves, and every TICK seconds at least"""
453+
last, said = None, time.time()
454+
while running():
455+
time.sleep(0.5)
456+
stage, n, of = build_stage(repo)
457+
if (stage and stage != last) or time.time() - said >= TICK:
458+
try: took = time.time() - os.path.getmtime(os.path.join(repo, '.axiomcode', 'build.lock'))
459+
except OSError: took = 0
460+
print(f" … {'language %d of %d, ' % (n, of) if of and n else ''}{stage or 'starting'} ({int(took)}s)", file=sys.stderr, flush=True)
461+
last, said = stage, time.time()
390462

391463

392464
def no_graph(repo, db):
393465
"""what to say when there is still no graph after ensure_graph has had its turn."""
466+
if _BUILD_NOTE:
467+
return _BUILD_NOTE[-1]
394468
if os.environ.get('AXIOMCODE_GRAPH'):
395469
return f"no graph at {db} (AXIOMCODE_GRAPH is set, so nothing was built into it)"
396470
if os.environ.get('AXIOMCODE_AUTOBUILD'):

‎plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py‎

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -76,19 +76,24 @@ def python_script(p, name=None):
7676
def watched(root, lang):
7777
"""every file the parser of `lang` reads under root; `lang` may be a comma list (a repository in several
7878
languages, each with its own graph), and a file two of them read is yielded once"""
79-
if ',' in lang:
80-
seen = set()
81-
for l in lang.split(','):
82-
for p in watched(root, l):
83-
if p not in seen: seen.add(p); yield p
84-
return
85-
exts, names, prune = EXT.get(lang, ()), NAMES.get(lang, ()), PRUNE.get(lang, PRUNE_ALL)
79+
# ONE WALK FOR EVERY LANGUAGE. A repository in five languages was walked five times on every freshness check, that is
80+
# before every query; the tree is walked once now, a directory only one language prunes (C#'s obj/bin/packages)
81+
# entered for the others and its files kept from that one. The files yielded are the same set.
82+
langs = [l for l in lang.split(',') if l] if lang else ['']
83+
common = set.intersection(*[set(PRUNE.get(l, PRUNE_ALL)) for l in langs])
84+
extra = {l: set(PRUNE.get(l, PRUNE_ALL)) - common for l in langs}
85+
spec = [(l, EXT.get(l, ()), NAMES.get(l, ())) for l in langs]
86+
hidden = {root: frozenset()} # the languages that pruned each directory walked
8687
for d, subdirs, files in os.walk(root):
87-
subdirs[:] = [s for s in subdirs if s not in prune]
88+
off = hidden.pop(d, frozenset())
89+
subdirs[:] = [s for s in subdirs if s not in common and not all(l in off or s in extra[l] for l in langs)]
90+
for s in subdirs: hidden[os.path.join(d, s)] = off | {l for l in langs if s in extra[l]}
8891
for f in files:
89-
if f.endswith(exts) or f in names or (lang == 'java' and os.path.basename(d) == 'services' and 'META-INF' in d) \
90-
or (lang == 'python' and python_script(os.path.join(d, f), f)):
91-
yield os.path.join(d, f)
92+
for l, exts, names in spec:
93+
if l in off: continue
94+
if f.endswith(exts) or f in names or (l == 'java' and os.path.basename(d) == 'services' and 'META-INF' in d) \
95+
or (l == 'python' and python_script(os.path.join(d, f), f)):
96+
yield os.path.join(d, f); break
9297

9398
def digest(p):
9499
h = hashlib.sha1()

0 commit comments

Comments
 (0)