Skip to content

Run one session's hooks one at a time, and retry a blocked read - #156

Merged
Saksham Uboweja (SakshamUboweja) merged 1 commit into
microsoft:mainfrom
akqmffl:fix/serialize-session-hooks
Nov 6, 2026
Merged

Saksham Uboweja (SakshamUboweja) merged 1 commit into
microsoft:mainfrom
akqmffl:fix/serialize-session-hooks

Conversation

@akqmffl

Copy link
Copy Markdown
Contributor

Fixes #142. Based on main after #140, following the plan in the issue.

What changes

  • Store.read retries a Windows PermissionError the way Store.write does since Windows: retry a blocked state save; find sh in the Codex tests #140, and returns default only for a missing file or bad JSON. Any other failure now raises, and the hook fails open as usual. _replace and read share a small _retrying helper.
  • plugin/hookqueue.py (new) runs a session's hooks under a per-session lock, kept in <root>/locks/<host>/ outside repository state. A hook that gets no lock in time saves its payload to the session's queue and answers {}. The lock holder applies the queue after its own event, and every later hook applies it before its own. SessionStart is never queued.
  • The store.py docstring no longer says a session document needs no lock because one session writes it.
  • The repro is now an offline test: tests/test_hook_queue.py::test_tool_calls_made_in_parallel_are_all_recorded.

What happens to a nudge from a queued PostToolUse

The hook that queued the call has already returned {}, so nothing can deliver a redirect on its behalf. Our local version of this patch got that wrong, which your question caught: replaying the call ran the whole decision, so the nudge was spent, the segment reset and the nudge event logged, and then the answer was thrown away.

Here a decision reached on a replayed call is not acted on:

  1. post_tool stores it on the turn (TurnGuard.held). fold stops deciding while one is held, so a burst of queued calls gives one decision.
  2. The turn's next live PostToolUse or PostToolUseFailure acts on it through the normal path. The budget, the log, nudge_body and the notice all happen there, so the answer carries that hook's own event name, which Claude Code requires. The stop notice works the same way.
  3. If the turn ends first, _close_turn drops it and logs an undelivered event. No nudge is counted, and there is no one left to redirect.

So a redirect can arrive one call late, and it is lost only when the turn ends on the queued call. For that to matter a hook has to wait out the full 3 s. An earlier version of this patch ran on one Windows machine for about a day of real sessions, and no hook had to queue.

Two other answers a queued hook would lose

  • UserPromptSubmit under repomap=on can carry the repository map, so it is not queued in that mode. It waits, then runs without the lock, like SessionStart. With the map off it always answers {}.
  • A queued SessionEnd that no later hook applies is picked up by pending_sessions() at the next session start, so the session still gets folded.

Waits

Each wait stays short of the shortest timeout any agent gives that hook, and a test reads plugin/hooks/*.json to pin it. Codex allows SessionEnd only 3 s, so that wait is 1 s. Our local version waited 5 s, which would overrun it.

Event Wait
SessionStart 10 s
UserPromptSubmit, Stop 5 s
PostToolUse, PostToolUseFailure 3 s
SessionEnd 1 s

Results

The repro from the issue on Windows, 5 rounds × 9 calls. It is a race, so main's numbers are the range over three runs.

Code Recorded Lost errors.log entries
main @ a71684c (with #140) 24–28 / 45 17–21 0
This branch 45 / 45 in all three runs 0 0

Real headless Claude Code 2.1.317 sessions on Windows, with the plugin built from each side and haiku making 8 parallel Read calls in one message, 2 runs each:

Code Calls recorded
main @ a71684c 7 / 8 in both runs
This branch 8 / 8 in both runs

Tests

Suite main @ a71684c This branch
Windows 11, Python 3.10, tests experiments/tests 1074 passed, 39 failed, 36 skipped 1096 passed, 39 failed, 36 skipped
Linux container, Python 3.11, tests 724 passed, 9 failed, 19 skipped 746 passed, 9 failed, 19 skipped

The failing tests are the same on both sides of each row. On Windows, the 39 are the known failures in experiments/tests. The Linux container has no git, which is the error its failures show, so CI is the real check there.

We also broke the code on purpose in a copy, to confirm the new tests notice:

  • With no lock, 10 tests fail.
  • With the replay acting at once, as our local patch did, 5 fail.
  • With a blocked read returning default again, 2 fail.
  • With a 5 s SessionEnd wait, 1 fails.

Not in this PR

  • The 14 os.replace calls outside store.py have no retry. Our local patch retried them all, but the plan here covers Store.read and Store.write.
  • Store.read_lines still returns [] on any OSError.
  • A queued PostToolUse replayed after a newer prompt has opened its turn closes that turn as one we never saw end, as any out-of-order hook does today. It takes a hook that waits out its 3 s across a new prompt.

🤖 Generated with Claude Code

An agent that calls tools in parallel starts their hooks together. Each
reads the session document, folds its call in and writes it back, so the
last writer won and the loop guard's turn lost 17-21 of 45 calls in the
repro in microsoft#142. On Windows a read during that race raised PermissionError,
which Store.read took for a missing document, so the next save could
write an empty state over the real one.

- Store.read retries a blocked read on Windows, as Store.write does since
  microsoft#140, and returns the default only for a missing file or bad JSON.
- hookqueue runs a session's hooks under a per-session lock kept outside
  repository state. A hook that gets no lock in time queues its event for
  the holder and answers {}. SessionStart, and UserPromptSubmit under
  repomap=on, answer with context, so they wait and then run.
- A queued hook has already answered, so a guard decision reached on a
  replayed call is held on the turn, uncounted, and the turn's next call
  acts on it. A turn that ends first drops it as an `undelivered` event.

Fixes microsoft#142

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@akqmffl

Copy link
Copy Markdown
Contributor Author

@microsoft-github-policy-service agree

@SakshamUboweja
Saksham Uboweja (SakshamUboweja) merged commit 6378a58 into microsoft:main Nov 6, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Parallel tool calls lose loop-guard state: one session's hooks race on the session document (and fail on Windows)

2 participants