From 4b8f34e15285071b7e25d6852ff1c4bafd42ff50 Mon Sep 17 00:00:00 2001 From: 1AbdullahButt <168034895+1AbdullahButt@users.noreply.github.com> Date: Thu, 12 Nov 2026 00:09:44 +0500 Subject: [PATCH] plugin: keep a held session lock fresh, so the daily prune leaves it Taking a session's hook lock writes nothing to its file, so the file's mtime stayed at the session's first hook. Once the session was open for more than IDLE_S, `_prune` removed the lock file while a hook held it, and on POSIX the session's next hook locked a new file and ran beside the holder. The hook that takes the lock now touches its file first. Fixes #210 Co-Authored-By: Claude Opus 5.5 --- tests/test_hook_queue.py | 25 +++++++++++++++++++++++++ tokeneconomizer/plugin/hookqueue.py | 8 ++++++++ 2 files changed, 33 insertions(+) diff --git a/tests/test_hook_queue.py b/tests/test_hook_queue.py index f94c041..bd10572 100644 --- a/tests/test_hook_queue.py +++ b/tests/test_hook_queue.py @@ -222,6 +222,31 @@ def test_idle_locks_go_and_a_queue_with_events_stays(tmp_path): assert sorted(n for n in os.listdir(base_dir) if n != ".pruned") == [".kept.lock", "kept.queue"] +def test_the_prune_leaves_the_lock_of_a_session_older_than_idle(tmp_path): + """A session's lock file is made at its first hook, and taking the lock writes nothing to it, + so the file of a session running for longer than IDLE_S looked idle. The prune removed it + while a hook held it, and on POSIX a parallel hook of that session then locked a new file + and ran beside the holder.""" + base_dir = os.path.join(str(tmp_path), "locks", "claude") + os.makedirs(base_dir) + lock = os.path.join(base_dir, ".s.lock") + open(lock, "w", encoding="utf-8").close() + old = time.time() - hookqueue.IDLE_S - 60 + os.utime(lock, (old, old)) + ctrl = SimpleNamespace(ctx=SimpleNamespace(root=str(tmp_path)), + host=SimpleNamespace(name="claude"), sid="s", p={}, + cfg=SimpleNamespace(repomap="off")) + beside = [] + + def handler(): + with Store(base_dir).locked("s", timeout=0.1) as got: + beside.append(got) + return {} + assert hookqueue.run(ctrl, "PostToolUse", handler) == {} + assert beside == [False] + assert os.path.getmtime(lock) > old + hookqueue.IDLE_S + + # ---- what a queued hook would have said ----------------------------------------------------- def stuck_turn(env, repo): """A repository the guard has learned, the calls of a stuck turn, and at which of them the diff --git a/tokeneconomizer/plugin/hookqueue.py b/tokeneconomizer/plugin/hookqueue.py index e5ba1ff..12d7d0b 100644 --- a/tokeneconomizer/plugin/hookqueue.py +++ b/tokeneconomizer/plugin/hookqueue.py @@ -143,6 +143,14 @@ def run(ctrl, event, handler): name = _safe(ctrl.sid) queue = os.path.join(base, name + ".queue") with Store(base).locked(name, timeout=WAITS.get(event, 3.0)) as got: + if got: + # Taking a lock writes nothing to its file, so its age would be the session's, and + # `_prune` would remove the lock of a session older than IDLE_S while a hook holds + # it: on POSIX the session's next hook would lock a new file and run beside it. + try: + os.utime(os.path.join(base, f".{name}.lock")) + except OSError: + pass _prune(base) if got: drain(ctrl, queue)