Repository navigation
fix(hooks): remove pending-calls state when a session ends - #297
Merged
Merged
Conversation
Hook sessions left an empty `.pending-calls*.json` file in the trace store after SessionEnd for Claude Code, Codex and Copilot CLI. - Delete a pending-calls file when its map becomes empty, and remove the ending session's own provider-scoped file at SessionEnd. The shared unscoped file only loses entries recorded for the ending session. - Read-modify-write pending calls under the store's `.store.lock` so parallel tool hooks cannot lose entries or race an unlink. Paths are resolved before locking and no locked block appends events, updates metadata or constructs a store, because the lock is not re-entrant. - Calls still pending at SessionEnd are dropped from the state file; the `tool_call` events stay in events.ndjson where unpaired calls are reported. - Tests cover all three providers, other sessions' and providers' files, sessions ending without a provider ID, Copilot subagent linking, lock nesting, and parallel hook processes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
After a hook session ends, the trace store still contains an empty pending-calls state file. Reproduced by driving
start → pre-tool → post-tool → session-endthrough the hook handlers onmain:.pending-calls.v2.claude.<hex>.<hash>.json{}.pending-calls.v2.codex.<hex>.<hash>.json{}.pending-calls.v2.copilot.<hex>.<hash>.json{}Fix (
src/agent_trace/hooks.py)_write_pending_callsdeletes the file when the map is empty, so post-tool and SessionStart reset no longer write{}._clear_pending_callsdeletes the ending session's provider-scoped file. A SessionEnd without a provider session ID is resolved through the canonical marker. In that case it deletes only this provider's scoped files for that local session, found by an exact name match. The shared unscoped.pending-calls.jsoncan hold several sessions' entries, so only entries tagged with this session'ssession_idare removed from it; new entries carry that tag. Other sessions' and other providers' files are never touched..store.lock(store.write_lock()), so parallel tool hooks cannot lose each other's entries or race the delete-when-empty unlink. This uses the same lock and per-session file scoping as before.flockis not re-entrant, and constructing aTraceStorecan take the lock during journal recovery. So pending paths are resolved from the caller's store before locking, and no locked block appends events, updates metadata or constructs a store.Calls still unmatched at SessionEnd are dropped
Each pending entry is written only after its
tool_callevent is inevents.ndjson, so the file is just an index used for linking results to calls. Readers that report orphaned or unpaired calls (evidence health, OTLP, timeline, review) readevents.ndjson, not this file. SessionStart, including resume, already resets the map. The Copilot subagent parent lookup (_pending_subagent_call) only needs the entry while the parent session is running. A result that arrives after SessionEnd is still recorded, just without a link to its call. Keeping the entries would leave the file this issue is about while giving readers nothing they don't already have.Tests
tests/test_hooks.py, run for claude, codex and copilot:unpaired_tool_call, and a late result is recorded unlinked without recreating the file;TraceStore.__init__to record any nested acquisition or store construction under the lock, without actually blocking.fcntl, uses a shared bounded timeout that kills the processes and fails, and uses no sleeps.tests/test_copilot_subagent_link.py: the subagent flow still linksparent_event_idto the task call and leaves no pending file. Ending the child does not touch the parent's file. The subagent hooks also pass the lock-nesting guard.The new tests fail on
main. The full suite passes locally.ADRs/0004is updated: its notes on pending calls (keyed by tool name, no locking) were out of date.