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

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. 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. 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:

// 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:

// 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:

// 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 Core RPC handler implementing fork command and destroy()
AGENTS.md Architecture documentation on wrapper lifecycle requirements
hooks/useAgentSession.ts Client-side hook for session operations
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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →