# What Happens During `fork()` and Why the Agent Session Wrapper Must Be Destroyed Immediately After

> Discover what happens during fork() and why the AgentSessionWrapper must be destroyed immediately after. Learn how fork() corrupts future session operations if the wrapper is not destroyed.

- Repository: [Alex Yang/pi-web](https://github.com/agegr/pi-web)
- Tags: internals
- Published: 2026-08-16

---

**Immediately after `fork()` succeeds, the `AgentSessionWrapper` must be destroyed because `fork()` mutates the inner `AgentSession` in place, leaving the global registry with a stale entry that corrupts future session operations if left intact.**

The **pi-web** repository implements session forking through a precisely controlled lifecycle in [`rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/rpc-manager.ts). Understanding this mechanism is critical for anyone building multi-session AI agents or debugging session tree corruption issues.

## How `fork()` Mutates the Session In Place

When a user triggers a fork via the API or UI, the backend executes the **fork command case** in [`lib/rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/lib/rpc-manager.ts). The operation modifies state in a non-obvious way that creates a registry inconsistency.

### The Three-Step Mutation Chain

1. **Inner session replacement** — The `AgentSessionWrapper` holds an `AgentSession` object. Calling `fork()` on this session **replaces `inner.sessionId` with the new forked session's ID** without creating a new wrapper instance.

2. **Registry desynchronization** — The wrapper remains registered in `globalThis.__piSessions` under the *original* session ID. The registry entry now points to a wrapper whose internal state describes the *forked* session, not the original.

3. **Cascade failure risk** — Any subsequent request using the original session ID retrieves this corrupted wrapper. Additional forks or navigation operations see the wrong `parentSession` chain, producing broken session trees in the UI.

This design trades object creation for performance but requires explicit cleanup to maintain consistency.

## The Critical `destroy()` Call

The fix is immediate wrapper destruction. Here's the implementation from [`lib/rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/lib/rpc-manager.ts):

```ts
// lib/rpc-manager.ts – fork command handler
case "fork": {
  if (this.state.shellRunning) throw new Error("Cannot fork while a shell command is running");
  const entry = this.state.entryMap.get(entryId);
  if (!entry) throw new Error("Invalid entry ID for forking");

  const forkedPath = sourceManager.createBranchedSession(entry.parentId);
  if (!forkedPath) throw new Error("Failed to create forked session");
  newSessionFile = forkedPath;

  // CRITICAL: Remove stale wrapper before returning
  this.destroy();
  return { cancelled: false };
}

```

The `this.destroy()` call removes the wrapper from `globalThis.__piSessions`. This forces the next request for the original session ID to instantiate a **fresh `AgentSessionWrapper`** loaded from the original session file on disk.

## What Happens If You Skip `destroy()`

Omitting the destruction step produces subtle, hazardous bugs:

| Scenario | Consequence |
|----------|-------------|
| Second fork using original ID | Creates nested fork with wrong parent chain |
| Navigation in "original" session | Operates on forked session state instead |
| Concurrent requests | Race conditions between stale and fresh wrappers |
| Session tree display | Shows corrupted hierarchy in `BranchNavigator` |

The architecture documentation in **AGENTS.md** explicitly warns against this: *"Fork must destroy the wrapper immediately"* to prevent these failure modes.

## Wrapper Re-Creation Mechanism

After destruction, subsequent requests trigger fresh wrapper instantiation:

```ts
// lib/rpc-manager.ts – wrapper retrieval with lazy initialization
function getWrapper(sessionId: string): AgentSessionWrapper {
  let wrapper = globalThis.__piSessions?.get(sessionId);
  if (!wrapper) {
    // Stale wrapper absent: safe to create from disk
    wrapper = new AgentSessionWrapper(sessionId);
    globalThis.__piSessions?.set(sessionId, wrapper);
  }
  return wrapper;
}

```

This pattern ensures **session isolation**: each forked branch operates independently with correct parent metadata preserved in its own session file.

## Client-Side Fork Trigger

The React frontend initiates forks through a simple POST request:

```tsx
// components/BranchNavigator.tsx (simplified)
const handleFork = async () => {
  await fetch(`/api/agent/${sessionId}`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ cmd: "fork", entryId: currentEntryId })
  });
};

```

The client assumes the server correctly manages wrapper lifecycle—making the `destroy()` call a contract that must be honored for predictable behavior.

## Source File Reference

| File | Purpose |
|------|---------|
| [`lib/rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/lib/rpc-manager.ts) | Core RPC handler implementing `fork` command and `destroy()` |
| [`AGENTS.md`](https://github.com/agegr/pi-web/blob/main/AGENTS.md) | Architecture documentation on wrapper lifecycle requirements |
| [`hooks/useAgentSession.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAgentSession.ts) | Client-side hook for session operations |
| [`components/BranchNavigator.tsx`](https://github.com/agegr/pi-web/blob/main/components/BranchNavigator.tsx) | UI component exposing fork functionality |

## Summary

- **`fork()` mutates in place** — The inner `AgentSession` changes its `sessionId` to the forked session, but the wrapper object persists.
- **Registry becomes stale** — `globalThis.__piSessions` maps the original ID to a wrapper now describing the wrong session.
- **`destroy()` restores consistency** — Removing the wrapper forces fresh instantiation from disk on next access.
- **Skip at your peril** — Failure to destroy causes session tree corruption, wrong parent chains, and broken UI state.

## Frequently Asked Questions

### What exactly does `AgentSessionWrapper.destroy()` do?

It removes the wrapper instance from the global `__piSessions` Map, marks internal state as destroyed, and prevents further operations on that wrapper object. This ensures no code can accidentally use the stale reference.

### Why not create a new wrapper instead of mutating in place?

Performance and reference stability. The wrapper manages connections, event listeners, and state subscriptions. Creating new instances would require re-establishing all these resources. In-place mutation with explicit cleanup achieves the same isolation with lower overhead.

### How can I detect if a wrapper wasn't destroyed properly?

Symptoms include: session navigation jumping to unexpected branches, fork operations creating chains with wrong parent IDs, and `BranchNavigator` displaying sessions in incorrect hierarchical positions. Check server logs for duplicate wrapper registrations under different IDs.

### Is this pattern used for other RPC commands?

No. Only `fork()` requires immediate destruction because it's the sole command that replaces the inner session identity while preserving the wrapper shell. Other commands like `navigate` or `execute` modify state without changing the fundamental session identity.