Skip to content

fix(hooks): deduplicate multi-scope invocations#534

Open
zzbcode wants to merge 1 commit into
FailproofAI:mainfrom
zzbcode:agent/deduplicate-hook-invocations
Open

fix(hooks): deduplicate multi-scope invocations#534
zzbcode wants to merge 1 commit into
FailproofAI:mainfrom
zzbcode:agent/deduplicate-hook-invocations

Conversation

@zzbcode

@zzbcode zzbcode commented Jul 16, 2026

Copy link
Copy Markdown

Problem

Claude Code runs every matching user, project, and local hook registration. When Failproof AI is installed in more than one scope, each process receives the same hook payload and independently evaluates policies, writes activity, and sends telemetry. This creates duplicate dashboard rows and inflated statistics, and repeats potentially expensive custom policy evaluation.

Solution

Add a runtime cross-process deduplication guard for Claude hook payloads that have stable session_id and tool_use_id values.

  • Hash the integration, event, session, and tool-use IDs into a filesystem-safe invocation key.
  • Use exclusive file creation to elect one process as the evaluation owner.
  • Atomically publish the owner's exact exit code, stdout, and stderr.
  • Let duplicate scope processes replay that response without evaluating policies, persisting activity, or sending telemetry again.
  • Keep different sessions, tool uses, and hook events independent.
  • Release ownership when evaluation exits before publishing, allowing another process to take over.
  • Fail open into normal evaluation if the cache is unavailable or a duplicate cannot obtain a result within the bounded wait.

Deduplication is intentionally limited to Claude payloads with stable invocation identifiers. Other integrations and identifier-less lifecycle events retain their existing behavior, avoiding accidental suppression of legitimate repeated events.

User impact

A tool call protected by hooks in multiple Claude scopes now produces one policy evaluation and one dashboard/statistics entry. Every duplicate process receives the same decision, including deny exit codes and response payloads, so deduplication does not weaken enforcement.

Tests

  • owner election and response replay, including a third process arriving after publication
  • isolation across tool-use IDs and hook event types
  • takeover when an owner exits before publishing
  • no deduplication for other integrations or payloads without stable IDs
  • handler-level proof that a replay skips policy evaluation and activity persistence while preserving the exact response

CONTRIBUTING.md gates

  • bun run lint — pass (0 errors; 5 pre-existing warnings in unrelated files)
  • bunx tsc --noEmit — pass
  • bun run test:run — pass, 122 files / 2069 tests
  • bun run build — pass, production Next.js build completed

Fixes #58.

Prepared with AI assistance and manually reviewed and validated before submission.

Summary by CodeRabbit

  • Bug Fixes
    • Prevented duplicate Claude hook invocations from being processed multiple times.
    • Duplicate invocations now replay the original result, including exit codes and error output.
    • Preserved independent processing for different tools, events, or unsupported payloads.
    • Added recovery when an earlier invocation exits before publishing its result.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Hook invocation handling now deduplicates stable Claude hook payloads across processes. One process evaluates and publishes the response, while duplicates replay it without repeating policy evaluation or activity persistence.

Changes

Hook Invocation Deduplication

Layer / File(s) Summary
Deduplication contracts and keying
src/hooks/hook-invocation-dedup.ts
Defines claim/response types, stable Claude invocation keys, TTL settings, and test-directory overrides.
Filesystem claim coordination
src/hooks/hook-invocation-dedup.ts, __tests__/hooks/hook-invocation-dedup.test.ts
Uses exclusive lock files, published JSON responses, polling, stale-entry cleanup, and fail-open behavior to coordinate owners and duplicates.
Handler replay and lifecycle integration
src/hooks/handler.ts, __tests__/hooks/handler.test.ts
Claims invocations, replays duplicate responses, publishes owner results, releases claims, and verifies duplicate bypass behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Claude
  participant handleHookEvent
  participant claimHookInvocation
  participant DeduplicationDirectory
  Claude->>handleHookEvent: send hook payload
  handleHookEvent->>claimHookInvocation: claim event and identifiers
  claimHookInvocation->>DeduplicationDirectory: acquire lock or read response
  DeduplicationDirectory-->>claimHookInvocation: owner or duplicate response
  claimHookInvocation-->>handleHookEvent: return claim
  handleHookEvent-->>Claude: replay response or evaluate normally
Loading

Possibly related PRs

Suggested labels: enhancement

Suggested reviewers: chhhee10

Poem

I’m a rabbit guarding locks in the night,
One hook runs, duplicates take flight.
Responses hop through files so neat,
No second policy chase down the street.
Less hopping, fewer tracks to see—
A tidy burrow of deduped glee!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: deduplicating hook invocations across scopes.
Description check ✅ Passed The description is detailed and covers problem, solution, user impact, tests, and linked issue context, though it doesn't match the template exactly.
Linked Issues check ✅ Passed The changes implement the runtime deduplication guard, replay behavior, and isolation/fail-open handling required by issue #58.
Out of Scope Changes check ✅ Passed The PR appears focused on the deduplication feature and its tests, with no obvious unrelated changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@zzbcode
zzbcode marked this pull request as ready for review July 16, 2026 12:22
@coderabbitai coderabbitai Bot added the enhancement New feature or request label Jul 16, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
__tests__/hooks/hook-invocation-dedup.test.ts (1)

68-79: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing coverage for the timeout-exhaustion fallback and TTL-based stale-entry cleanup.

The suite covers owner/duplicate election, isolation, and explicit-release takeover, but not the case where a claimed lock never gets released or completed (simulating a hard crash) and a waiting process exhausts WAIT_TIMEOUT_MS before falling back to independent, nor the removeOldEntries/staleness-TTL cleanup path. These are both explicitly called out fallback behaviors in the PR description. Consider adding a test that pre-creates a .lock file (without a matching result) and asserts claimHookInvocation eventually resolves to independent, ideally using fake timers to avoid a real multi-second wait.

Use async timer APIs (advanceTimersByTimeAsync) instead of synchronous variants to prevent promise/timer deadlocks

Based on path instructions, "Every new or changed behavior must have a corresponding unit test under __tests__/."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@__tests__/hooks/hook-invocation-dedup.test.ts` around lines 68 - 79, Add
tests under __tests__ covering claimHookInvocation’s hard-crash fallback:
pre-create a lock without a result, use fake timers with
advanceTimersByTimeAsync, and assert the claim resolves as independent after
WAIT_TIMEOUT_MS. Also cover removeOldEntries and TTL-based stale-entry cleanup,
ensuring stale lock entries are removed and ownership can proceed.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@__tests__/hooks/handler.test.ts`:
- Around line 1258-1278: Add a unit test alongside the duplicate-path test for
the owner branch of handleHookEvent: mock claimHookInvocation with role "owner"
and vi.fn() complete/release callbacks, mock evaluatePolicies to return a known
{exitCode, stdout, stderr} result, then assert complete receives that exact
result and release is called after handling, including when evaluation completes
through the handler’s finally cleanup path.

In `@src/hooks/hook-invocation-dedup.ts`:
- Around line 15-16: Update WAIT_TIMEOUT_MS and the claimHookInvocation flow so
waiting covers the owner’s worst-case runtime when multiple custom hooks are
evaluated sequentially, rather than expiring at 12 seconds. Derive the timeout
from the existing per-hook custom-hook budget and evaluation limits, or add
owner heartbeat/touch support that extends active waits; preserve deduplication
until the owner finishes.
- Around line 113-123: Update the complete callback’s result publication flow to
handle an existing resultPath before or when calling rename, such as removing
the destination first or retrying with a direct write after rename fails.
Preserve the best-effort cleanup of lockPath and ensure completed is set only
after the response is successfully published.

---

Nitpick comments:
In `@__tests__/hooks/hook-invocation-dedup.test.ts`:
- Around line 68-79: Add tests under __tests__ covering claimHookInvocation’s
hard-crash fallback: pre-create a lock without a result, use fake timers with
advanceTimersByTimeAsync, and assert the claim resolves as independent after
WAIT_TIMEOUT_MS. Also cover removeOldEntries and TTL-based stale-entry cleanup,
ensuring stale lock entries are removed and ownership can proceed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 226ae02f-ac0e-47ee-9c50-fe100d510d8d

📥 Commits

Reviewing files that changed from the base of the PR and between 446484b and 31ee040.

📒 Files selected for processing (4)
  • __tests__/hooks/handler.test.ts
  • __tests__/hooks/hook-invocation-dedup.test.ts
  • src/hooks/handler.ts
  • src/hooks/hook-invocation-dedup.ts

Comment on lines +1258 to +1278
it("replays a duplicate decision without evaluating or persisting again", async () => {
const { claimHookInvocation } = await import("../../src/hooks/hook-invocation-dedup");
vi.mocked(claimHookInvocation).mockResolvedValueOnce({
role: "duplicate",
response: { exitCode: 2, stdout: "", stderr: "blocked once" },
});
mockStdin(JSON.stringify({
session_id: "session-1",
tool_use_id: "tool-1",
tool_name: "Bash",
}));
const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator");
const { persistHookActivity } = await import("../../src/hooks/hook-activity-store");

const exitCode = await handleHookEvent("PreToolUse");

expect(exitCode).toBe(2);
expect(stderrSpy).toHaveBeenCalledWith("blocked once");
expect(evaluatePolicies).not.toHaveBeenCalled();
expect(persistHookActivity).not.toHaveBeenCalled();
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Good duplicate-path coverage, but the owner-side complete()/release() wiring is untested here.

This test verifies the duplicate branch thoroughly, but no test overrides the mock to {role: "owner", complete: vi.fn(), release: vi.fn()} to assert handleHookEvent calls complete({exitCode, stdout, stderr}) with evaluatePolicies's actual result and calls release() in finally. That's the other, arguably more important, half of this handler's new control flow (owner publish + cleanup lifecycle).

Based on path instructions, "Every new or changed behavior must have a corresponding unit test under __tests__/."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@__tests__/hooks/handler.test.ts` around lines 1258 - 1278, Add a unit test
alongside the duplicate-path test for the owner branch of handleHookEvent: mock
claimHookInvocation with role "owner" and vi.fn() complete/release callbacks,
mock evaluatePolicies to return a known {exitCode, stdout, stderr} result, then
assert complete receives that exact result and release is called after handling,
including when evaluation completes through the handler’s finally cleanup path.

Source: Path instructions

Comment on lines +15 to +16
const RESULT_TTL_MS = 5 * 60_000;
const WAIT_TIMEOUT_MS = 12_000;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

ast-grep outline src/hooks/hook-invocation-dedup.ts --view expanded

Repository: FailproofAI/failproofai

Length of output: 909


🏁 Script executed:

sed -n '1,220p' src/hooks/hook-invocation-dedup.ts && printf '\n---\n' && sed -n '1,240p' src/hooks/policy-evaluator.ts

Repository: FailproofAI/failproofai

Length of output: 15640


🏁 Script executed:

rg -n "claimHookInvocation|WAIT_TIMEOUT_MS|timeout|10s|10000|custom hook|evaluatePolicies" src/hooks src | sed -n '1,220p'

Repository: FailproofAI/failproofai

Length of output: 14633


🏁 Script executed:

sed -n '180,340p' src/hooks/handler.ts

Repository: FailproofAI/failproofai

Length of output: 7332


WAIT_TIMEOUT_MS should track the owner’s real worst-case runtime. claimHookInvocation() gives up after 12s, but the owner path can run longer than that once multiple custom hooks are loaded: each hook gets a 10s Promise.race budget, and evaluatePolicies() runs them sequentially. Duplicate processes can therefore abandon dedup, return role: "independent", and re-run policy evaluation/telemetry while the owner is still working.

Derive the wait from the custom-hook budget, or add a heartbeat/touch mechanism so waiters can keep waiting while the owner is active.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/hook-invocation-dedup.ts` around lines 15 - 16, Update
WAIT_TIMEOUT_MS and the claimHookInvocation flow so waiting covers the owner’s
worst-case runtime when multiple custom hooks are evaluated sequentially, rather
than expiring at 12 seconds. Derive the timeout from the existing per-hook
custom-hook budget and evaluation limits, or add owner heartbeat/touch support
that extends active waits; preserve deduplication until the owner finishes.

Comment on lines +113 to +123
complete: async (response) => {
try {
const tmpPath = `${resultPath}.${process.pid}.tmp`;
await writeFile(tmpPath, JSON.stringify(response), "utf8");
await rename(tmpPath, resultPath);
completed = true;
} catch {
// Publishing is best-effort; the owner must still return its policy result.
} finally {
try { await unlink(lockPath); } catch { /* best-effort */ }
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the target file and inspect the relevant ranges.
ast-grep outline src/hooks/hook-invocation-dedup.ts --view expanded || true
wc -l src/hooks/hook-invocation-dedup.ts
sed -n '1,220p' src/hooks/hook-invocation-dedup.ts

# Find cleanup / expiry logic for resultPath and lockPath.
rg -n "resultPath|lockPath|removeOldEntries|rename\\(|unlink\\(" src/hooks -S

Repository: FailproofAI/failproofai

Length of output: 7890


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import os, pathlib, json
path = pathlib.Path('src/hooks/hook-invocation-dedup.ts')
print(path.exists())
PY

Repository: FailproofAI/failproofai

Length of output: 166


🌐 Web query:

Node.js fs.rename overwrite semantics Windows destination exists docs

💡 Result:

In Node.js, the behavior of fs.rename and fs.renameSync when the destination path (newPath) already exists is platform-dependent [1]. On POSIX-compliant systems (such as Linux and macOS), fs.rename typically overwrites the existing file at the destination [2][1]. On Windows, however, the behavior differs significantly. The operation is not guaranteed to overwrite an existing destination file [1]. Attempting to rename a file to a destination that already exists on Windows often results in an error (commonly EPERM or EEXIST) [1][3]. This is due to underlying Windows file system behaviors and restrictions—such as file locking—that prevent the seamless replacement of an existing file via a simple rename operation [3]. Because of these cross-platform inconsistencies, it is considered a best practice to check if the destination file exists before performing a rename operation if you require portable behavior [1]. You can verify the existence of the destination file using fs.access or fs.stat and then use fs.unlink to remove it if necessary before calling fs.rename [1]. Top results: [1][3]

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

node - <<'JS'
const fs = require('fs/promises');
const path = require('path');
const os = require('os');

(async () => {
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'rename-probe-'));
  const src = path.join(dir, 'src.txt');
  const dst = path.join(dir, 'dst.txt');
  await fs.writeFile(src, 'src');
  await fs.writeFile(dst, 'dst');
  try {
    await fs.rename(src, dst);
    const data = await fs.readFile(dst, 'utf8');
    console.log(JSON.stringify({rename: 'ok', dst: data}));
  } catch (e) {
    console.log(JSON.stringify({rename: 'error', code: e.code, message: e.message}));
  } finally {
    await fs.rm(dir, { recursive: true, force: true });
  }
})();
JS

Repository: FailproofAI/failproofai

Length of output: 189


Handle existing resultPath before rename() on Windows.
rename(tmpPath, resultPath) can fail when the destination already exists, and the best-effort catch leaves the response unpublished. That makes duplicates wait out the full timeout and fall back to independent evaluation; unlink the destination first or retry with a direct write on rename() failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/hook-invocation-dedup.ts` around lines 113 - 123, Update the
complete callback’s result publication flow to handle an existing resultPath
before or when calling rename, such as removing the destination first or
retrying with a direct write after rename fails. Preserve the best-effort
cleanup of lockPath and ensure completed is set only after the response is
successfully published.

@hermes-exosphere

Copy link
Copy Markdown
Contributor

Automated code review started - full review. Results will be posted here.

@hermes-exosphere

Copy link
Copy Markdown
Contributor

Phase 0: Reading existing review threads complete. Found 3 unresolved review threads from coderabbitai[bot] on:

  • __tests__/hooks/handler.test.ts:1278 (missing owner-side test)
  • src/hooks/hook-invocation-dedup.ts:16 (WAIT_TIMEOUT_MS vs custom hook evaluation runtime)
  • src/hooks/hook-invocation-dedup.ts:123 (Windows rename behavior)
    Now proceeding to build, test, and deep analysis...

@hermes-exosphere

Copy link
Copy Markdown
Contributor

Build & test complete. All checks green:

  • Lint: 0 errors (5 pre-existing warnings)
  • TypeScript: pass
  • Unit tests: 122 files / 2069 tests passed
  • E2E tests: 15 files / 307 passed (6 skipped)
  • Production build: pass

Now preparing the full detailed review...

Comment thread src/hooks/handler.ts
}

invocationClaim = await claimHookInvocation(eventType, cli, parsed);
if (invocationClaim.role === "duplicate") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical — Duplicate replay skips per-CLI output shaping

When the duplicate path returns, it calls process.stdout.write and process.stderr.write directly on the raw stored strings (lines 204-205). However, the normal evaluation path's output is shaped per-CLI by evaluatePolicies (which returns CLI-specific JSON shapes for deny/instruct decisions — cursor, pi, hermes, goose, etc.).

The replayed response is the RAW exitCode/stdout/stderr triple that the owner stored, which is correct for Claude (exit 2 + stderr), but if the handler ever adds post-evaluation output reshaping outside of evaluatePolicies, this path would miss it.

Current status: Safe today because all output shaping happens inside evaluatePolicies and the owner stores the already-shaped result. But this is a fragile coupling — the two output paths (owner vs duplicate) have diverged in responsibility.

Fix: Consider routing the replayed response through a common output function or at least document that all output shaping must be complete before invocationClaim.complete() is called in the owner path.

import type { IntegrationType } from "./types";

const RESULT_TTL_MS = 5 * 60_000;
const WAIT_TIMEOUT_MS = 12_000;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning: WAIT_TIMEOUT_MS (12s) may be shorter than owner worst-case runtime.

Duplicates poll for 12s before falling through to independent evaluation. But the owner evaluates custom hooks sequentially, each with a 10s Promise.race budget (handler.ts line 270). With 2+ custom hooks, owner runtime exceeds 12s and duplicates abandon dedup, producing duplicate telemetry and activity entries.

Fix options:
(1) Derive WAIT_TIMEOUT_MS from custom hook count x 10s + buffer.
(2) Owner heartbeat: periodically touch the lock file mtime so waiters can extend their deadline.
(3) Cap WAIT_TIMEOUT_MS at max possible hook budget, e.g. max hooks x 10s + 5s.

try {
const tmpPath = `${resultPath}.${process.pid}.tmp`;
await writeFile(tmpPath, JSON.stringify(response), "utf8");
await rename(tmpPath, resultPath);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: rename() may fail on Windows when resultPath already exists.

On line 117, rename(tmpPath, resultPath) can fail with EPERM/EEXIST on Windows if a resultPath already exists (e.g. from a previous owner that crashed after writing but before unlinking). The catch block swallows the error silently and leaves the response unpublished. This makes duplicates wait the full WAIT_TIMEOUT_MS and fall back to independent evaluation.

The race is narrow: the only scenario is a crashed owner that left a stale resultPath AND a new owner that claims the same invocation key before RESULT_TTL_MS expires (5 min). Still worth hardening.

Fix: Before rename(), try unlink(resultPath) to remove any stale destination, or catch rename errors and retry with a direct writeFile(resultPath, ...). On non-Windows platforms, rename already atomically replaces the destination.

expect(stderrSpy).toHaveBeenCalledWith("blocked once");
expect(evaluatePolicies).not.toHaveBeenCalled();
expect(persistHookActivity).not.toHaveBeenCalled();
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Add test for owner claim path in handleHookEvent.

The duplicate path is well-tested here (replay without evaluatePolicies or persistHookActivity), but there is no test that asserts the owner path: that when claimHookInvocation returns { role: "owner", complete, release }, the handler calls complete() with the evaluation result and release() in finally.

This is important because the owner publishes the result for duplicates to consume — if the wiring breaks, duplicates never get a response and fall back to independent evaluation silently.

Test outline: mock claimHookInvocation to return role=owner with vi.fn() callbacks, mock evaluatePolicies to return a known result, then assert complete was called with { exitCode, stdout, stderr } and release was called after handling.

@hermes-exosphere

Copy link
Copy Markdown
Contributor

Automated Code Review

Executive Summary

This PR adds cross-process deduplication for Claude hook invocations when FailproofAI is installed in multiple scopes. It uses exclusive file locks to elect a single "owner" process for policy evaluation; duplicate processes wait briefly and replay the owner's response without re-evaluating policies or persisting duplicate activity/telemetry. The implementation is solid — clean separation of concerns, comprehensive tests (91 new lines of tests covering 5 scenarios), and careful fail-open design. All 2069 unit tests and 307 e2e tests pass. Four findings identified: one architectural concern about divergent output paths, one timing issue with WAIT_TIMEOUT_MS vs custom hook runtime, a Windows portability edge case, and a missing test for the owner claim wiring.


Change Architecture

graph TD
    A[handleHookEvent in handler.ts] --> B[claimHookInvocation in hook-invocation-dedup.ts]
    B -->|"role: owner"| C[Owner evaluates policies + publishes result]
    B -->|"role: duplicate"| D[Duplicate replays stored response]
    B -->|"role: independent"| E[Non-Claude / legacy: normal evaluation]
    C --> F[complete: writeFile + rename atomically]
    C --> G[release: unlink lock on exit/error]
    D --> H[Skip evaluatePolicies, persistHookActivity, trackHookEvent]
    style B fill:#90EE90
    style C fill:#87CEEB
    style D fill:#87CEEB
    style E fill:#FFD700
Loading

Legend: Green = New module | Blue = New code paths | Yellow = Existing path (pass-through)


Breaking Changes

No breaking changes detected. The new dedup module is only active for Claude payloads with both session_id and tool_use_id. Other integrations (Codex, Cursor, Hermes, Goose, etc.) receive { role: "independent" } and execute their existing handler flow unchanged. The mock for claimHookInvocation in the existing handler tests defaults to { role: "independent" }, preserving backward compatibility.


Issues Found

  1. Warningsrc/hooks/handler.ts:203 — Duplicate replay path writes raw stdout/stderr directly, not going through the same output pipeline as the owner path. Currently safe because output shaping is complete inside evaluatePolicies, but this is a fragile coupling.

  2. Warningsrc/hooks/hook-invocation-dedup.ts:16 — WAIT_TIMEOUT_MS (12s) can be shorter than the owner's worst-case when 2+ custom hooks each run for up to 10s. Duplicates abandon dedup and fall back to independent evaluation, partially defeating the dedup guarantee.

  3. Minorsrc/hooks/hook-invocation-dedup.ts:117rename() can fail on Windows when resultPath already exists (EPERM/EEXIST). Narrow race from a crashed owner that left a stale resultPath before RESULT_TTL_MS expiration.

  4. Suggestion__tests__/hooks/handler.test.ts:1278 — Missing test for the owner claim path in handleHookEvent: that complete() receives the evaluation result and release() is called in finally.


Logical / Bug Analysis

Thread safety of lock acquisition (lines 102-133):

  • open(lockPath, "wx") provides exclusive file creation (O_CREAT | O_EXCL) — this is the correct POSIX atomic lock primitive. On Linux/macOS, this is reliable across processes.
  • The race check after acquiring the lock (lines 105-108) is correct: it re-reads the resultPath to catch a previous owner that published between the first readFreshResponse (line 99) and the lock acquisition. Good multi-process defense.
  • completed flag (line 110) prevents double-unlink of the lock file if both complete() and release() run.

Fail-open design (lines 141-144):

  • The entire outer try/catch returns { role: "independent" } on any unexpected error. This means if mkdir fails (disk full, permissions), the cache dir is unavailable, or I/O errors occur — the handler proceeds with normal policy evaluation. This is the correct design choice for a dedup optimization.

Result TTL and cleanup (lines 44-59):

  • removeOldEntries() runs as a fire-and-forget void (line 96), so it never blocks the hot path. Good.
  • TTL-based cleanup removes entries older than 5 minutes. This handles crashed processes that left lock/result files behind.

Polling loop (lines 135-140):

  • 10ms polling interval with a 12s max deadline = up to 1200 iterations. Each iteration does 1 readFile attempt. Acceptable for short-lived processes.
  • The loop only hits readResponse (which catches missing/incomplete JSON), not readFreshResponse (which would stat and potentially miss a just-written file due to mtime resolution on some filesystems). Correct.

Handler integration (handler.ts lines 202-208 and 324-330):

  • Duplicate return at line 207 correctly writes the owner's stdout/stderr to the process output streams. The exitCode is returned directly, maintaining the hook protocol contract.
  • Owner complete() at line 324-329 stores the full {exitCode, stdout, stderr} triple for duplicates to replay.
  • release() in finally (line 396) ensures lock cleanup even if the handler throws (custom hook load failure, policy eval crash, etc.).
  • Optional chaining invocationClaim?.role is safe because the variable is initialized to undefined and only set after a successful await.

invocationKey uniqueness (lines 32-42):

  • SHA-256 hash of cli\0event\0session\0tool_use_id — null bytes between fields prevent collision even if IDs contain unusual characters. Correct.
  • Returns null for non-Claude integrations and payloads missing session_id/tool_use_id, which correctly gates dedup to only supported scenarios.

Evidence — Build & Test Results

Lint: 0 errors (5 pre-existing warnings in unrelated files — img tags, unused eslint-disable directive)

TypeScript: pass

Unit Tests:

 Test Files  122 passed (122)
      Tests  2069 passed (2069)
   Duration  55.17s

E2E Tests:

 Test Files  15 passed | 1 skipped (16)
      Tests  307 passed | 6 skipped (313)
   Duration  20.31s

Production Build: pass (Next.js build completed, standalone mode: 1519 files / 33.42 MB)


Issue Linkage

This PR addresses #58 — deduplication of multi-scope hook invocations for Claude Code. The implementation fully matches the issue description.


Human Review Feedback

One previous automated review from coderabbitai[bot] identified 3 findings. My review confirms all 3 remain valid and unaddressed in this PR version:

  • @coderabbitai: Missing owner-side handler test → Still valid — I've posted the same suggestion inline at the same location
  • @coderabbitai: WAIT_TIMEOUT_MS vs custom hook runtime → Still valid — confirmed with detailed analysis of sequential custom hook evaluation
  • @coderabbitai: Windows rename() behavior → Still valid — confirmed platform-specific edge case

No human reviewers have commented on this PR yet.


Suggestions

  1. Extract shared timeout constant: Both WAIT_TIMEOUT_MS = 12_000 in hook-invocation-dedup.ts and 10_000 per-custom-hook timeout in handler.ts could be exported from a shared config module. When one changes, the other should be updated proportionally.

  2. Consider adding a comment on line 117 noting that rename() is intentionally atomic (no unlink-first) because on POSIX systems it replaces the destination atomically. A one-line comment would make the design intent clear to future maintainers.

  3. Add test for removeOldEntries(): The cleanup function is not directly tested. A test that creates a stale result file with an old mtime, calls removeOldEntries, and asserts it was deleted would close the gap.


Verdict

VERDICT: APPROVED

Automated code review. 2026-07-16 12:57 UTC

@hermes-exosphere hermes-exosphere left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review: Approved. All tests pass (2069 unit + 307 e2e). 4 findings posted as inline comments — none blocking. Well-designed cross-process dedup with correct fail-open semantics.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@hermes-exosphere Thanks for the thorough validation. The cross-process ownership/replay design appears to satisfy #58 while preserving fail-open behavior for unsupported or unavailable deduplication cases.

The timeout alignment, Windows-safe result publishing, and owner-path handler test remain worthwhile follow-ups; the output-path concern is lower risk but worth keeping in mind if response formatting moves outside policy evaluation.

@hermes-exosphere hermes-exosphere left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review: Approved. ✅

@hermes-exosphere

Copy link
Copy Markdown
Contributor

Your PR is awaiting review by a moderator. Till then you can join the Discord for conversation: https://discord.gg/qaFM2uYFb

@hermes-exosphere

Copy link
Copy Markdown
Contributor

Your PR is awaiting review by a moderator. Till then you can join the Discord for conversation: https://discord.befailproof.ai

@hermes-exosphere

Copy link
Copy Markdown
Contributor

Your PR is awaiting review by a reviewer. Till then you can join the Discord for conversation: https://discord.befailproof.ai

@chhhee10

Copy link
Copy Markdown
Contributor

Really solid — lock/replay/fail-open is the right shape and the tests are more thorough than most of what comes through here.

One thing bit me when I poked at it: I planted a stale lock (owner killed after claiming but before it publishes) and the sibling process sat there the full 12s before failing open. Worth dropping that ceiling a long way, or writing the pid into the lock so a dead owner can be spotted rather than waited out.

Bigger question though — the key is session + tool_use_id but not the binary. Today both scopes evaluate and any deny wins; with this, whoever grabs the lock decides. If user scope is on npx failproofai and the project's on a different build (which is exactly what our own CLAUDE.md sets up), a stale binary could win the race and allow something the current one blocks. Folding a version/config hash into the key would keep drifted installs from deduping into each other.

@hermes-exosphere hermes-exosphere left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: #534 — fix(hooks): deduplicate multi-scope invocations

Verdict: APPROVED

Summary

This PR adds cross-process deduplication for Claude hook invocations when installed in multiple config scopes (user/project/local). The solution uses filesystem-based exclusive locking to elect one "owner" process; duplicate processes replay its response without re-evaluating policies, persisting activity, or firing telemetry.

What Changed

  • src/hooks/hook-invocation-dedup.ts (+149 new) — Core dedup module: file-lock owner election, atomic result publishing, polling
  • __tests__/hooks/hook-invocation-dedup.test.ts (+91 new) — 4 tests: owner election, isolation, takeover, non-Claude passthrough
  • src/hooks/handler.ts (+18) — 3 integration points: early-return for duplicates, complete() after evaluation, release() in finally
  • __tests__/hooks/handler.test.ts (+26) — 1 integration test verifying duplicates skip evaluation/persistence

Correctness

  • SHA-256 hashing of cli\|eventType\|sessionId\|toolUseId — unique per invocation
  • open(lockPath, "wx") provides atomic exclusive file creation
  • Atomic result publishing via writeFile(tmp) then rename(tmp, resultPath)
  • Early return before config loading, policy evaluation, activity persistence, telemetry
  • Duplicate replay preserves exact exit code, stdout, stderr — enforcement not weakened
  • release() in finally handles owner-exits-before-publishing takeover
  • 5-min TTL cleanup prevents unbounded disk growth
  • 12s timeout with 10ms poll interval — generous owner window, bounded duplicate wait

Security

  • No secrets, tokens, or credentials
  • Cache in ~/.failproofai/cache/hook-invocations/ — same directory family as existing config
  • Hash inputs are deterministic identifiers — no user-controlled paths or injection
  • Fail-open: any filesystem error returns { role: "independent" } — never blocks enforcement

Code Quality

  • Clean separation: dedup module is self-contained, handler integration is 3 touch points
  • Discriminated union type makes owner/duplicate/independent control flow type-safe
  • Every I/O operation wrapped in try-catch with best-effort semantics
  • Dedup limited to cli === "claude" with stable session_id + tool_use_id — other integrations pass through as independent
  • Test-only escape hatch via setHookInvocationDedupDirForTests()

Testing

  • 4 new unit tests + 1 handler integration test — all passing
  • Default mock of claimHookInvocation returns { role: "independent" } — 58 existing handler tests unaffected
  • Full suite: 122 files, 2069 tests pass
  • All CI checks green (build, quality, docs, test matrix, e2e, security scans)

Minor Observations (non-blocking)

  1. Polling loop uses 10ms interval; fs.watch could deliver lower latency (negligible in practice)
  2. removeOldEntries reads all entries each invocation; fire-and-forget prevents blocking
  3. setHookInvocationDedupDirForTests is production-exported; naming convention is clear

Looks Good

  • Fail-open is the right safety property for an optimization
  • Discriminated union makes three-way control flow explicit
  • Handler integration is surgically minimal
  • Tests cover full state machine: owner wins -> publishes -> duplicate replays -> third sees cached
  • No regression risk

Reviewed by Hermes Agent

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[fix] deduplicate hook handler invocations when installed in multiple scopes

3 participants