Skip to content

Commit 2ac093a

Browse files
Merge pull request #1862 from AxiomCodeAI/fix/refresh-batch-trigger
refresh: batch edits where a rebuild is slow
2 parents eb0d952 + 07b133f commit 2ac093a

3 files changed

Lines changed: 76 additions & 9 deletions

File tree

‎plugins/axiomcode/hooks/refresh.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@
4040
import ax_fresh
4141
for d in repos:
4242
n = ax_fresh.hook_kick(d, f"the {ev.get('hook_event_name') or 'hook'} hook" + (f" after {ev['tool_name']}" if ev.get('tool_name') else ''),
43-
session=str(ev.get('session_id') or ''))
43+
session=str(ev.get('session_id') or ''), event=ev.get('hook_event_name') or '',
44+
tool=ev.get('tool_name') or '')
4445
if n: said.append(n if len(repos) == 1 else f"{d}: {n}")
4546
except Exception:
4647
pass # a hook never fails the tool call it rides on

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

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@
3535
3636
Environment: AXIOMCODE_NO_REFRESH=1 turns every trigger off (a query verb's --no-refresh, and the MCP tools' refresh=false,
3737
set it for that one query: a read-only answer from the graph as it is, still saying which edits it predates); AXIOMCODE_REFRESH_DEBOUNCE (seconds, default 2)
38-
is the quiet window; AXIOMCODE_REFRESH_MAX (default 2, 0 = no cap) is how many background rebuilds run at once on
38+
is the quiet window; AXIOMCODE_REFRESH_BATCH (default 3; 1 = off) is how many edits a rebuild waits for once the last build
39+
took AXIOMCODE_REFRESH_BATCH_ABOVE seconds (default 20); AXIOMCODE_REFRESH_MAX (default 2, 0 = no cap) is how many background rebuilds run at once on
3940
the machine, the rest queued; AXIOMCODE_FRESH_WAIT (seconds, default 30) is the most a query whose answer touches an edited file
4041
waits for a refresh expected to finish within it, AXIOMCODE_FRESH=1 (--fresh) makes it wait for the refresh whatever it
4142
takes, up to AXIOMCODE_FRESH_MAX (default 600); AXIOMCODE_BUILD_WAIT
@@ -935,14 +936,51 @@ def refresher_running(repo):
935936

936937
def running_line(repo, s): return started_line(repo, s, running=True)
937938

938-
def hook_kick(repo, trigger, session=''):
939+
# EDITS ARE BATCHED WHERE A REBUILD IS SLOW. Every edit started the refresher, which rebuilt after a 2 s quiet window: on
940+
# a repository whose build takes 5 s that keeps the graph current for free, but where it takes a minute (1,300 Java
941+
# files: 48-73 s cold, and 50-88 s again after a one- or two-file edit, since the solve is whole-program) an agent
942+
# editing every few seconds kept a rebuild running back to back. So once the last build took AXIOMCODE_REFRESH_BATCH_ABOVE
943+
# seconds (default 20), an edit tool only counts, and the AXIOMCODE_REFRESH_BATCH-th edit (default 3; 1 = off) starts the
944+
# rebuild; a shell command waits for the next edit or checkpoint. The end of a turn, a prompt, a session start, the
945+
# timer and every query still refresh at once, so the graph is current whenever the agent stops or asks.
946+
EDIT_TOOLS = ('Edit', 'Write', 'MultiEdit', 'NotebookEdit')
947+
948+
def batch_size():
949+
try: return max(1, int(os.environ.get('AXIOMCODE_REFRESH_BATCH') or 3))
950+
except ValueError: return 3
951+
952+
def batching(repo):
953+
"""the edits a rebuild waits for here: batch_size() when the last build was slow, else 1 (every edit rebuilds)"""
954+
try: above = float(os.environ.get('AXIOMCODE_REFRESH_BATCH_ABOVE') or 20)
955+
except ValueError: above = 20.0
956+
secs, _ = build_seconds(repo)
957+
return batch_size() if secs is not None and secs >= above else 1
958+
959+
def batch_due(repo):
960+
n = batching(repo)
961+
return n <= 1 or read_state(repo).get('edits', 0) >= n
962+
963+
def batch_held(repo, event, tool):
964+
"""True when this hook event only counts toward the next rebuild; any other trigger makes the next one due"""
965+
n = batching(repo)
966+
if n <= 1: return False
967+
if event != 'PostToolUse':
968+
write_state(repo, edits=n); return False
969+
if tool not in EDIT_TOOLS: return True
970+
k = read_state(repo).get('edits', 0) + 1
971+
write_state(repo, edits=k)
972+
return k < n
973+
974+
def hook_kick(repo, trigger, session='', event='', tool=''):
939975
"""what a hook (and the MCP server's timer) runs in place of kick: '' after starting the refresher as kick does, or,
940976
for a graph another axiomcode built (engine_change), nothing started and one line to say so, once per session and
941-
difference ('' when it was said already). A graph a newer axiomcode built is never rebuilt anyway (newer_build)"""
977+
difference ('' when it was said already). A graph a newer axiomcode built is never rebuilt anyway (newer_build).
978+
`event` and `tool` name the hook event and the tool it followed, for batching edits (batch_held)"""
942979
if not enabled(repo): return ''
943980
held = engine_change(repo)
944981
if not held:
945-
kick(repo, trigger); return ''
982+
if not batch_held(repo, event, tool): kick(repo, trigger)
983+
return ''
946984
key = f"{session}|{held}"
947985
if read_state(repo).get('hook_held') == key: return ''
948986
write_state(repo, hook_held=key)
@@ -1060,8 +1098,9 @@ def worker(repo):
10601098
if not _flock(fd, False): return 0 # single flight
10611099
debounce = float(os.environ.get('AXIOMCODE_REFRESH_DEBOUNCE') or 2); legacy_done = False; engine_done = ''; eng = ''
10621100
try:
1063-
for _ in range(8): # bounded: a tree rewritten faster than it builds must not spin forever
1101+
for i in range(8): # bounded: a tree rewritten faster than it builds must not spin forever
10641102
time.sleep(debounce)
1103+
if i and not batch_due(repo): return 0 # edits made during the build wait for their batch (batching)
10651104
t = load_table(repo)
10661105
if not has_graph(repo): return 0
10671106
nb = newer_build(repo, t) if t else ''
@@ -1087,7 +1126,7 @@ def worker(repo):
10871126
if eng and eng == engine_done: eng = ''
10881127
if (c is None or not any(c)) and not eng and not base_moved(repo):
10891128
if export_behind(t): rewarm(repo, t)
1090-
write_state(repo, state='fresh', checked=time.time(), checked_by=os.environ.get('AXIOMCODE_REFRESH_TRIGGER', '')); return 0
1129+
write_state(repo, state='fresh', checked=time.time(), checked_by=os.environ.get('AXIOMCODE_REFRESH_TRIGGER', ''), edits=0); return 0
10911130
c = c or [[], [], []]
10921131
st = read_state(repo)
10931132
# a build that FAILED on exactly this tree is not retried on every trigger: the next edit retries it
@@ -1099,7 +1138,7 @@ def worker(repo):
10991138
engine_done = eng
11001139
env = rebuild_env(t, AXIOMCODE_BACKGROUND='1', AXIOMCODE_REFRESH_REASON=why)
11011140
slot = take_slot(repo) # queued behind the machine's other background builds
1102-
t0 = time.time(); write_state(repo, state='building', started=t0, files=sum(len(x) for x in c))
1141+
t0 = time.time(); write_state(repo, state='building', started=t0, files=sum(len(x) for x in c), edits=0)
11031142
if any(c): print(f"{time.strftime('%H:%M:%S')} refresh: {sum(len(x) for x in c)} file(s) changed ({', '.join((c[0] + c[1] + c[2])[:5])}) — rebuilding", flush=True)
11041143
if eng: print(f"{time.strftime('%H:%M:%S')} refresh: {eng}; rebuilding", flush=True)
11051144
elif not any(c): print(f"{time.strftime('%H:%M:%S')} refresh: HEAD moved — moving the baseline to it", flush=True)
@@ -1123,7 +1162,7 @@ def worker(repo):
11231162
# an edit that landed after the last check but while the lock was still held found the worker busy and
11241163
# returned; it is picked up here, after the lock is released, by starting over
11251164
c = changes(repo)
1126-
if (c and any(c) and read_state(repo).get('failed_table') != change_key(c)) or base_moved(repo): kick(repo)
1165+
if ((c and any(c) and read_state(repo).get('failed_table') != change_key(c)) or base_moved(repo)) and batch_due(repo): kick(repo)
11271166
return 0
11281167

11291168
def failure_reason(out):

‎tests/refresh.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,33 @@ def watch():
273273
time.sleep(0.2)
274274
check(rebuilds() > n0, f'{lang}: the refresher it started rebuilt the graph')
275275

276+
# ── edits batched where a rebuild is slow ───────────────────────────────────────────────────────────
277+
# BATCH_ABOVE=0 takes this graph's build for a slow one: two edits and a shell command start nothing, the
278+
# third edit does, and the end of a turn rebuilds after a single edit. The control is the check above: the
279+
# same hook on the same graph with the default threshold rebuilt after one edit
280+
slow = dict(env, AXIOMCODE_REFRESH_BATCH_ABOVE='0')
281+
def hook(event, tool=None):
282+
e = {'hook_event_name': event, 'cwd': repo, 'session_id': 't'}
283+
if tool: e.update(tool_name=tool, tool_input={'file_path': f} if tool == 'Edit' else {'command': 'true'})
284+
return sh(repo, sys.executable, HOOK, env=slow, stdin=json.dumps(e))
285+
def settle(n, secs): # the rebuild count once nothing is building
286+
deadline = time.time() + secs
287+
while time.time() < deadline and not (rebuilds() > n and json.loads(sh(repo, sys.executable, FRESH, 'status', '.', '--json', env=slow).stdout or '{}').get('state') == 'fresh'):
288+
time.sleep(0.2)
289+
return rebuilds()
290+
n0 = rebuilds()
291+
for _ in range(2):
292+
open(f, 'a').write('\n'); hook('PostToolUse', 'Edit')
293+
hook('PostToolUse', 'Bash')
294+
check(settle(n0, 4) == n0, f'{lang}: where a rebuild is slow, two edits and a shell command start no rebuild')
295+
open(f, 'a').write('\n'); hook('PostToolUse', 'Edit')
296+
check(settle(n0, 600) > n0, f'{lang}: the third edit starts it')
297+
n0 = rebuilds()
298+
open(f, 'a').write('\n'); hook('PostToolUse', 'Edit')
299+
check(settle(n0, 4) == n0, f'{lang}: the batch starts over after that rebuild')
300+
hook('Stop')
301+
check(settle(n0, 600) > n0, f'{lang}: the end of a turn rebuilds after a single edit')
302+
276303
# ── a graph from before the file table ────────────────────────────────────────────────────────────
277304
for x in ('files.json', 'base-tree', 'base-commit'):
278305
if os.path.exists(os.path.join(out, x)): os.remove(os.path.join(out, x))

0 commit comments

Comments
 (0)