# How ai-memory Handles Local Spooling of Events for Native Commands

> Discover how ai-memory ensures uninterrupted native command execution by locally spooling events, preventing network blocks and improving performance.

- Repository: [Fabio Akita/ai-memory](https://github.com/akitaonrails/ai-memory)
- Tags: internals
- Published: 2026-08-21

---

**ai-memory eliminates network blocking during native command execution by persisting lifecycle-hook events to a private local filesystem spool, asynchronously draining them to the remote server only at session boundaries or when storage budgets are exceeded.**

Native commands in the **akitaonrails/ai-memory** repository trigger lifecycle hooks such as `pre-tool-use` and `post-tool-use` that must be captured without delaying the user workflow. Instead of synchronously POST-ing to a remote endpoint, the Rust CLI implements a high-performance strategy for **local spooling of events** that writes payloads to disk and flushes them later. This architecture ensures that tool invocations remain instantaneous while guaranteeing eventual delivery of telemetry and memory updates.

## The Spool Architecture: Filesystem as a Buffer

The spool implementation centers on [`crates/ai-memory-cli/src/commands/hook_spool.rs`](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-cli/src/commands/hook_spool.rs), which treats the local filesystem as a durable, zero-dependency message queue.

### Spool Directory Structure and Permissions

The function `spool_dir(data_dir)` returns a private subdirectory `<data_dir>/hook-spool`. On Unix systems, the directory is created with mode `0700` and every spool file with mode `0600`, ensuring only the owning user can inspect captured payloads.

### Event Representation with SpoolEntry

Each queued event is serialized as a `SpoolEntry` struct that captures:
- The target URL for the webhook
- The JSON payload body
- A creation timestamp (milliseconds since epoch)
- The **authentication mode** (`Static`, `Oidc`, or `Anonymous`)
- A retry attempt counter

This metadata allows the drain logic to resolve authentication tokens lazily and handle retries without re-serializing the payload.

## Enqueuing Events Without Blocking

The `enqueue` function provides the non-blocking write path that makes local spooling viable for real-time command hooks.

### Atomic File Writes and Privacy Controls

To prevent partial writes from corrupting the spool, `enqueue` writes to a temporary file with a `.tmp` extension and then atomically renames it to a filename embedding the creation time, process ID, and a monotonic sequence: `{created_ms}-{pid}-{seq}.json`. This naming convention ensures total ordering while eliminating race conditions during enqueue operations.

### Spool Pruning and Health Monitoring

Unbounded growth is prevented by `prune_spool_file_count`, which enforces a hard limit of `MAX_SPOOL_FILES` (10,000). When the count is exceeded, the oldest entries are evicted and a warning is emitted to `stderr`. Operators can inspect queue depth via `spool_health`, which efficiently computes pending count, oldest age, and total retry attempts by reading filenames only—never opening individual files.

## Draining and Network Transmission

Draining converts filesystem entries into HTTP requests. The process is orchestrated by several drain variants including `drain_exclusive` and `drain_until_quiescent`.

### Exclusive Locking with DrainLock

Concurrent drains are prevented by `acquire_drain_lock`, which creates a `.drain.lock` file and attempts an exclusive lock via `fs2::FileExt::try_lock_exclusive`. The lock is automatically released when the `DrainLock` object is dropped, ensuring that even panicked processes do not deadlock the spool.

### Batch Processing and Retry Logic

The `drain` function sorts spool files oldest-first and constructs payloads via `batch_payload`. Requests are batched up to `MAX_BATCH_ITEMS` (256 events) or `MAX_BATCH_BYTES` (8 MiB). If the server does not support batching, the implementation falls back to per-event `POST /hook`.

Failed deliveries trigger `bump_or_drop`, which increments the `attempts` field. Entries exceeding `MAX_ATTEMPTS` (8) or `MAX_AGE_MS` (7 days) are permanently deleted to prevent poison-pill accumulation.

### Authentication Resolution at Drain Time

Tokens are resolved lazily to support short-lived OIDC credentials. The `entry_bearer` function caches the OIDC token for the duration of a single drain pass, enabling `Oidc` auth mode entries to fetch fresh credentials from [`auth.json`](https://github.com/akitaonrails/ai-memory/blob/main/auth.json) at flush time rather than at capture time.

## Session Boundaries and Drain Triggers

Drains are triggered at well-defined lifecycle points: `session-start`, `session-end`, and cancellation handlers. The helper `drain_exclusive_within_budget` enforces time and size constraints, ensuring that a long-running session does not block indefinitely on network I/O while still guaranteeing forward progress on the spool.

```rust
use ai_memory_cli::commands::hook_spool::{
    spool_dir, enqueue, entry_for, drain_exclusive, DrainLockWait,
};

/// Capture a hook event without blocking the native command
fn capture_event(data_dir: &std::path::Path) {
    // Build entry with Anonymous auth (no static token, no OIDC)
    let entry = entry_for(
        "https://example.com/hook?event=tool-use".into(),
        r#"{"tool":"git","args":["commit"]}"#.into(),
        None,
        false,
    );
    
    // Instantaneous local write; no network I/O
    let spool = spool_dir(data_dir);
    enqueue(&spool, &entry).expect("spool write failed");
}

/// Drain spool at session exit with budget constraints
#[tokio::main]
async fn drain_at_exit(data_dir: &std::path::Path) {
    let spool = spool_dir(data_dir);
    
    // Try exclusive lock; skip if another process is draining
    if let Some(_result) = drain_exclusive(
        &spool,
        data_dir,
        std::time::Duration::from_secs(30),   // total budget
        std::time::Duration::from_secs(5),    // per-event timeout
        DrainLockWait::Bounded(std::time::Duration::from_secs(30)),
    )
    .await
    {
        println!("Spool drained successfully");
    } else {
        println!("Another process is draining; skipping");
    }
}

```

## Summary

- **ai-memory** decouples event capture from network transmission by writing hook events to a private local spool in [`crates/ai-memory-cli/src/commands/hook_spool.rs`](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-cli/src/commands/hook_spool.rs).
- **Atomic enqueuing** via `enqueue` uses temporary files and strict Unix permissions (mode `0600`) to guarantee durability and privacy without blocking the calling tool.
- **Bounded growth** is enforced by `prune_spool_file_count` (max 10,000 files) and `bump_or_drop` (max 8 attempts or 7 days age).
- **Exclusive draining** through `acquire_drain_lock` prevents race conditions, while batching (256 items or 8 MiB) optimizes network throughput.
- **Lazy authentication** allows `Oidc` tokens to be resolved at drain time, supporting short-lived credentials for webhook delivery.

## Frequently Asked Questions

### What prevents the spool from growing indefinitely?

The `prune_spool_file_count` function caps the total file count at `MAX_SPOOL_FILES` (10,000) and evicts the oldest entries when exceeded. Additionally, `bump_or_drop` removes individual entries that exceed `MAX_ATTEMPTS` (8 retries) or `MAX_AGE_MS` (7 days), ensuring disk usage remains bounded even if the remote server is unavailable for extended periods.

### How does ai-memory ensure that enqueuing never blocks on network I/O?

The `enqueue` function performs only local filesystem operations: it serializes a `SpoolEntry` to a temporary file and atomically renames it into the spool directory. Network transmission is deferred to the `drain` phase, which runs asynchronously at session boundaries or during periodic maintenance, keeping native command execution latency minimal.

### Can multiple processes drain the spool simultaneously?

No. The `acquire_drain_lock` function creates an exclusive filesystem lock (`.drain.lock`) using `fs2::FileExt::try_lock_exclusive`. Only one process can hold this lock at a time; other callers receive `None` from `drain_exclusive` and skip the drain cycle, preventing duplicate deliveries and file-system race conditions.

### How are authentication tokens handled for spooled events?

Authentication is resolved lazily during the drain phase rather than at capture time. The `entry_bearer` function checks the `AuthMode` enum (`Static`, `Oidc`, or `Anonymous`) and caches OIDC tokens for the duration of the drain pass. This design allows events captured hours ago to be delivered with fresh credentials fetched from [`auth.json`](https://github.com/akitaonrails/ai-memory/blob/main/auth.json) at flush time.