fix(qmd): stop hook group growth on re-sync and harden the warm-up lock
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f3c88a089e
commit
f483ea7fe7
5 files changed
+76
-8
No files matched your search
@@ -12,7 +12,7 @@ def is_flashbang(hook):
|
|||||||
|
|
||||||
|
|
||||||
def is_qmd_warm(hook):
|
def is_qmd_warm(hook):
|
||||||
return any(re.search(r'(?:qmd-warm\.sh|Start-QmdWarm\.ps1)', str(hook.get(key, '')), re.I)
|
return any(re.search(r'(?:[/\\\s"\x27]|^)(?:qmd-warm\.sh|Start-QmdWarm\.ps1)(?:[\s"\x27]|$)', str(hook.get(key, '')), re.I)
|
||||||
for key in ('command', 'command_windows'))
|
for key in ('command', 'command_windows'))
|
||||||
|
|
||||||
|
|
||||||
@@ -165,10 +165,21 @@ def merge_toml(current_text, managed_text, statusline_enabled=True):
|
|||||||
managed_headers = {header for header, _ in managed_sections}
|
managed_headers = {header for header, _ in managed_sections}
|
||||||
extras = []
|
extras = []
|
||||||
fields_by_header = {}
|
fields_by_header = {}
|
||||||
for header, block in current_sections:
|
owned_pattern = r'(?:flashbang|statusline|record-compact|session-state-pointer|qmd-warm|Start-QmdWarm)\.(?:ps1|sh)'
|
||||||
|
for index, (header, block) in enumerate(current_sections):
|
||||||
plugin_table = re.match(r'^\[(?:plugins|marketplaces)(?:\.|\])', header)
|
plugin_table = re.match(r'^\[(?:plugins|marketplaces)(?:\.|\])', header)
|
||||||
array_table = header.startswith('[[')
|
array_table = header.startswith('[[')
|
||||||
owned_hook = any(re.search(r'(?:flashbang|statusline|record-compact|session-state-pointer|qmd-warm|Start-QmdWarm)\.(?:ps1|sh)', line, re.I) for line in block)
|
owned_hook = any(re.search(owned_pattern, line, re.I) for line in block)
|
||||||
|
group = re.fullmatch(r'\[\[hooks\.(\w+)\]\]', header)
|
||||||
|
if group:
|
||||||
|
# A hook group header is owned when every hook table that follows it is owned (or none follows).
|
||||||
|
child = f'[[hooks.{group.group(1)}.hooks]]'
|
||||||
|
children = []
|
||||||
|
for name, lines in current_sections[index + 1:]:
|
||||||
|
if name != child:
|
||||||
|
break
|
||||||
|
children.append(any(re.search(owned_pattern, line, re.I) for line in lines))
|
||||||
|
owned_hook = all(children)
|
||||||
if plugin_table and header in managed_headers:
|
if plugin_table and header in managed_headers:
|
||||||
raise ValueError('Cannot merge conflicting managed plugin tables.')
|
raise ValueError('Cannot merge conflicting managed plugin tables.')
|
||||||
if (header not in managed_headers and not array_table) or (array_table and not owned_hook):
|
if (header not in managed_headers and not array_table) or (array_table and not owned_hook):
|
||||||
|
|||||||
@@ -98,6 +98,44 @@ class InstallOptionsTests(unittest.TestCase):
|
|||||||
self.assertEqual(OPTIONS.filter_instructions(text, False), 'Line one.\nLine three.\n')
|
self.assertEqual(OPTIONS.filter_instructions(text, False), 'Line one.\nLine three.\n')
|
||||||
self.assertEqual(OPTIONS.filter_instructions(text, True), text)
|
self.assertEqual(OPTIONS.filter_instructions(text, True), text)
|
||||||
|
|
||||||
|
def test_qmd_warm_match_is_anchored(self):
|
||||||
|
self.assertTrue(OPTIONS.is_qmd_warm({'command': 'bash "/c/hooks/scripts/qmd-warm.sh"'}))
|
||||||
|
self.assertTrue(OPTIONS.is_qmd_warm({'command_windows': 'pwsh "C:\hooks\Start-QmdWarm.ps1"'}))
|
||||||
|
self.assertFalse(OPTIONS.is_qmd_warm({'command': 'bash "/c/hooks/scripts/my-qmd-warm.sh"'}))
|
||||||
|
|
||||||
|
def test_merge_json_keeps_user_session_hook_and_drops_qmd_when_disabled(self):
|
||||||
|
user = {'type': 'command', 'command': 'bash "/home/me/my-start.sh"'}
|
||||||
|
pointer = {'type': 'command', 'command': 'bash "/c/hooks/scripts/show-session-state-pointer.sh"'}
|
||||||
|
qmd = {'type': 'command', 'command': 'bash "/c/hooks/scripts/qmd-warm.sh"'}
|
||||||
|
current = {'hooks': {'SessionStart': [{'hooks': [user]}]}}
|
||||||
|
managed = {'hooks': {'SessionStart': [{'hooks': [pointer]}, {'hooks': [qmd]}]}}
|
||||||
|
once = OPTIONS.merge_json(current, managed)
|
||||||
|
twice = OPTIONS.merge_json(once, managed)
|
||||||
|
self.assertEqual(twice['hooks']['SessionStart'], [{'hooks': [pointer]}, {'hooks': [qmd]}, {'hooks': [user]}])
|
||||||
|
disabled = OPTIONS.merge_json(twice, {'hooks': {'SessionStart': [{'hooks': [pointer]}]}})
|
||||||
|
commands = [hook['command'] for group in disabled['hooks']['SessionStart'] for hook in group['hooks']]
|
||||||
|
self.assertEqual(commands, [pointer['command'], user['command']])
|
||||||
|
removed = OPTIONS.merge_json(twice, {'hooks': {}})
|
||||||
|
self.assertEqual(removed['hooks']['SessionStart'], [{'hooks': [user]}])
|
||||||
|
|
||||||
|
def test_merge_toml_hook_groups_do_not_grow_and_user_hook_survives(self):
|
||||||
|
managed = ('[features]\nhooks = true\n\n[[hooks.SessionStart]]\n[[hooks.SessionStart.hooks]]\ntype = "command"\n'
|
||||||
|
'command = \'bash "/c/hooks/scripts/qmd-warm.sh"\'\n\n[[hooks.Stop]]\n[[hooks.Stop.hooks]]\ntype = "command"\n'
|
||||||
|
'command = \'bash "/c/hooks/flashbang.sh"\'\n')
|
||||||
|
user = '\n[[hooks.SessionStart]]\n[[hooks.SessionStart.hooks]]\ntype = "command"\ncommand = \'bash "/home/me/my-start.sh"\'\n'
|
||||||
|
text = managed + user
|
||||||
|
for _ in range(3):
|
||||||
|
text = OPTIONS.merge_toml(text, managed)
|
||||||
|
hooks = tomllib.loads(text)['hooks']
|
||||||
|
self.assertEqual(len(hooks['Stop']), 1)
|
||||||
|
self.assertEqual(len(hooks['SessionStart']), 2)
|
||||||
|
commands = [hook['command'] for group in hooks['SessionStart'] for hook in group['hooks']]
|
||||||
|
self.assertEqual(sorted(commands), ['bash "/c/hooks/scripts/qmd-warm.sh"', 'bash "/home/me/my-start.sh"'])
|
||||||
|
disabled = OPTIONS.merge_toml(text, '[features]\nhooks = true\n')
|
||||||
|
hooks = tomllib.loads(disabled)['hooks']
|
||||||
|
self.assertNotIn('Stop', hooks)
|
||||||
|
self.assertEqual([hook['command'] for group in hooks['SessionStart'] for hook in group['hooks']], ['bash "/home/me/my-start.sh"'])
|
||||||
|
|
||||||
def test_filter_preserves_other_claude_hooks_and_status(self):
|
def test_filter_preserves_other_claude_hooks_and_status(self):
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
path = Path(directory) / 'settings.json'
|
path = Path(directory) / 'settings.json'
|
||||||
|
|||||||
@@ -23,3 +23,19 @@ test('a stale lock older than 30 minutes is replaced', async () => {
|
|||||||
await withLock(lock, async () => { runs += 1; });
|
await withLock(lock, async () => { runs += 1; });
|
||||||
assert.equal(runs, 1);
|
assert.equal(runs, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an empty or non-numeric lock counts as stale', async () => {
|
||||||
|
for (const content of ['', 'garbage']) {
|
||||||
|
const lock = join(mkdtempSync(join(tmpdir(), 'qmd-warm-')), 'repo.lock');
|
||||||
|
writeFileSync(lock, content);
|
||||||
|
let runs = 0;
|
||||||
|
await withLock(lock, async () => { runs += 1; });
|
||||||
|
assert.equal(runs, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a run never removes a lock that another run took over', async () => {
|
||||||
|
const lock = join(mkdtempSync(join(tmpdir(), 'qmd-warm-')), 'repo.lock');
|
||||||
|
await withLock(lock, async () => { writeFileSync(lock, 'other-run'); });
|
||||||
|
assert.equal(existsSync(lock), true);
|
||||||
|
});
|
||||||
@@ -2,6 +2,6 @@
|
|||||||
# SessionStart: warm the QMD index and daemon in the background; never blocks or fails the session.
|
# SessionStart: warm the QMD index and daemon in the background; never blocks or fails the session.
|
||||||
script="$HOME/.my-ai-configuration/qmd/qmd-warm.mjs"
|
script="$HOME/.my-ai-configuration/qmd/qmd-warm.mjs"
|
||||||
if [ -f "$script" ] && command -v node >/dev/null 2>&1 && git rev-parse --show-toplevel >/dev/null 2>&1; then
|
if [ -f "$script" ] && command -v node >/dev/null 2>&1 && git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||||
nohup node "$script" >/dev/null 2>&1 &
|
nohup node "$script" >/dev/null 2>&1 </dev/null &
|
||||||
fi
|
fi
|
||||||
exit 0
|
exit 0
|
||||||
@@ -10,18 +10,21 @@ import { startDaemon } from './qmd-search.mjs';
|
|||||||
const STALE_MS = 30 * 60_000;
|
const STALE_MS = 30 * 60_000;
|
||||||
|
|
||||||
export async function withLock(lockPath, fn, now = Date.now) {
|
export async function withLock(lockPath, fn, now = Date.now) {
|
||||||
|
const stamp = String(now());
|
||||||
try {
|
try {
|
||||||
writeFileSync(lockPath, String(now()), { flag: 'wx' });
|
writeFileSync(lockPath, stamp, { flag: 'wx' });
|
||||||
} catch {
|
} catch {
|
||||||
const age = now() - Number(readFileSync(lockPath, 'utf8'));
|
const age = now() - Number(readFileSync(lockPath, 'utf8'));
|
||||||
if (!(age > STALE_MS)) return false;
|
// An empty or non-numeric lock gives a NaN age and counts as stale.
|
||||||
writeFileSync(lockPath, String(now()));
|
if (Number.isFinite(age) && age <= STALE_MS) return false;
|
||||||
|
writeFileSync(lockPath, stamp);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await fn();
|
await fn();
|
||||||
return true;
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(lockPath, { force: true });
|
// Remove the lock only while it is still this run's own.
|
||||||
|
try { if (readFileSync(lockPath, 'utf8') === stamp) rmSync(lockPath, { force: true }); } catch { /* already gone */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user