Skip to content

fix(hooks): remove pending-calls state when a session ends - #297

Merged
Siddhant-K-code merged 1 commit into
mainfrom
siddhant-k-code-fix-pending-calls-cleanup
Oct 25, 2026
Merged

Siddhant-K-code merged 1 commit into
mainfrom
siddhant-k-code-fix-pending-calls-cleanup

Conversation

@Siddhant-K-code

Copy link
Copy Markdown
Owner

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-end through the hook handlers on main:

Provider File left after SessionEnd Contents
claude .pending-calls.v2.claude.<hex>.<hash>.json {}
codex .pending-calls.v2.codex.<hex>.<hash>.json {}
copilot .pending-calls.v2.copilot.<hex>.<hash>.json {}

Fix (src/agent_trace/hooks.py)

  • No empty files. _write_pending_calls deletes the file when the map is empty, so post-tool and SessionStart reset no longer write {}.
  • SessionEnd clears only this session's state. _clear_pending_calls deletes 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.json can hold several sessions' entries, so only entries tagged with this session's session_id are removed from it; new entries carry that tag. Other sessions' and other providers' files are never touched.
  • Safe under concurrency. Every read-modify-write of a pending-calls file now holds the existing .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.
  • No nested locking. flock is not re-entrant, and constructing a TraceStore can 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_call event is in events.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) read events.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:
    • no pending file remains after the full lifecycle;
    • another session's and another provider's files are byte-identical after SessionEnd;
    • unmatched calls are dropped from the state but still reported as unpaired_tool_call, and a late result is recorded unlinked without recreating the file;
    • SessionEnd without a provider ID clears only this session's state.
  • Lock-nesting guard: patches the store lock and TraceStore.__init__ to record any nested acquisition or store construction under the lock, without actually blocking.
  • Subprocess concurrency test: 8 parallel pre-tool, then 8 parallel post-tool hook processes on one session. All entries are recorded, each result links to its own call, and no file is left. It is skipped without 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 links parent_event_id to 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/0004 is updated: its notes on pending calls (keyed by tool name, no locking) were out of date.

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>
@Siddhant-K-code
Siddhant-K-code merged commit a31590e into main Oct 25, 2026
5 checks passed
@Siddhant-K-code
Siddhant-K-code deleted the siddhant-k-code-fix-pending-calls-cleanup branch October 25, 2026 10:46
Siddhant-K-code added a commit that referenced this pull request Oct 25, 2026
Patch release covering #296 (AGENT_TRACE_DIR as the default trace store) and #297 (pending-calls cleanup at session end).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
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.

1 participant