pleach
Architecture

Time travel

Event-granular session forking and state reconstruction — the read/write API over the checkpointer's history.

TimeTravelAPI is the navigable view over the checkpoint history a Checkpointer writes. It reconstructs StateSnapshot records from raw checkpoint rows, walks history newest-first, applies synthetic supersteps for replay, and forks a checkpoint into a new session.

import {
  TimeTravelAPI,
  type StateSnapshot,
  type StateSnapshotMetadata,
  type StateSnapshotConfig,
  type GetStateHistoryOptions,
} from "@pleach/core/time-travel";

This is the substrate-level fork surface. runtime.checkpoints.rollback is the higher-level path that rebuilds session state in place and bumps the version vector;TimeTravelAPI is what you reach for when you want to branch into a new session id or inspect snapshot internals without touching the live session.

The canonical runtime entry point is runtime.timeTravel.api — the facet returns the lazily-constructed TimeTravelAPI instance (or undefined when the runtime has no checkpointer). All four methods — getState, getStateHistory, bulkUpdateState, fork — live on that instance.

const api = runtime.timeTravel.api;
if (api) {
  const fork = await api.fork(sourceSessionId, checkpointId, newSessionId);
}

How it differs from runtime.checkpoints.rollback

runtime.checkpoints.rollbackTimeTravelAPI.fork
Target sessionSame session idNew session id
Live sessionMutated in placeUntouched
Version vectorBumps; writes a new source: "rollback" checkpointForked checkpoint has source: "fork", parent_id: source.id (cross-session edge)
Lineage edgeparentCheckpointId on the new rollback checkpointmetadata.forkedFrom: { sessionId, checkpointId } + cross-session parent_id
Event-log cursorInherited from the rollback targetInherited from the source checkpoint's metadata.lastEventSequence
Use caseRecover from a stuck turn, retry from earlier stateExplore an alternative branch without losing the original

Both write to the same checkpointer. Forks land in the new session's namespace; the source session keeps its own history intact.

Constructor

new TimeTravelAPI(
  checkpointer:      CheckpointerLike,
  createChannels:    () => Record<string, ChannelLike>,
  nodeSubscriptions: Record<string, string[]> = {},
);

createChannels is a factory that returns a fresh channel map keyed by channel name — the API uses it to replay reducer logic when applying synthetic supersteps. nodeSubscriptions maps node name to the set of channels each node subscribes to; it drives the next array on every returned StateSnapshot.

If you only need read methods (getState, getStateHistory, fork), an empty nodeSubscriptions is fine — next will simply be empty.

Methods

MethodReturnsNotes
getState(sessionId, checkpointId?)Promise<StateSnapshot | null>Latest when checkpointId is omitted.
getStateHistory(sessionId, options?)AsyncGenerator<StateSnapshot>Newest-first; limit caps iteration, before cursors, source filters by node.
bulkUpdateState(sessionId, supersteps)Promise<StateSnapshot>Replays writes through createChannels(), persists with source: "bulk_update".
fork(sourceSessionId, checkpointId, newSessionId)Promise<StateSnapshot>Copies channel state into a new session; sets parent_id: source.id (cross-session lineage edge).

StateSnapshot shape

interface StateSnapshot<S = Record<string, unknown>> {
  values:          S;                                  // reconstructed channel values
  pendingTasks:    Array<{ id; name; input }>;          // in-flight at snapshot time
  pendingWrites:   Array<{ taskId; channel; value }>;   // buffered but not committed
  metadata:        StateSnapshotMetadata;
  config:          StateSnapshotConfig;
  next:            string[];                            // nodes eligible to run next
  channelVersions: Record<string, number>;
  manifest?:       ChatManifestSnapshot;                // chat-manifest ledger AS-OF this checkpoint (inspection-only)
}

interface StateSnapshotMetadata {
  step:               number;
  timestamp:          string;        // ISO-8601
  source:             string;        // node or trigger that produced this state
  writtenBy:          string[];
  parents:            string[];      // parent checkpoint ids; cross-session for forks
  lastEventSequence?: number;        // event-log cursor at snapshot time
}

next is computed from channelVersions + versions_seen against the constructor's nodeSubscriptions — a node appears if any channel it subscribes to has advanced past the version it last saw. That's what makes a snapshot replayable: you can pick up exactly where the graph left off without re-running already-applied work.

metadata.parents is the parent-link chain — a single-element array for ordinary checkpoints, multi-element for merge scenarios. Forked snapshots populate parents with the source checkpoint id (a cross-session edge), so traversals walking metadata.parents no longer hide the fork's origin.

manifest is the chat-manifest ledger (tool/job invocations, notices, provider switches) AS-OF the checkpoint — surfaced for inspection (getState/getStateHistory/fork/bulkUpdate). It is OPTIONAL (undefined for an empty ledger or a legacy checkpoint). Inspection-only: it does NOT feed the next generation — that functional rehydration runs through rollbackToCheckpoint/resumeSession into the live ChatManifestProvider. The snapshot rides both the checkpoint metadata (this field) and state.extensions (the functional path) for the two distinct purposes.

Event-log cursor on the snapshot

metadata.lastEventSequence carries the event-log sequence_number the snapshot was taken at. A host hydrating from this snapshot can fast-forward intermediate state without re-folding the event log from genesis: pass the cursor straight to runtime.events.iterate({ chatId, fromSequenceNumber: snapshot.metadata.lastEventSequence }) and replay events forward from there. The same value flows into hydrateFromEvents() as fromSequenceNumber.

The field is undefined on checkpoints written before the cursor field landed — consumers must back-compat to "no skip" when it's missing.

Fork semantics

Which channels survive a fork is decided by the underlying channel_values blob: every channel in the source checkpoint copies verbatim into the new session. There is no per-channel survival policy at this layer — that lives in the reducer definition for each channel, and the fork respects whatever the snapshot already has.

const api = new TimeTravelAPI(checkpointer, createDefaultChannels);

const snapshot = await api.fork(
  sourceSessionId,
  "cp_018f...",
  crypto.randomUUID(),
);

snapshot.config.sessionId;              // new session id
snapshot.metadata.source;               // "fork"
snapshot.metadata.parents;              // [source.id] — cross-session edge
snapshot.metadata.lastEventSequence;    // inherited from the source checkpoint
// the lineage edge on the raw checkpoint metadata:
// metadata.forkedFrom = { sessionId: sourceSessionId, checkpointId: "cp_018f..." }

Fork lineage

A forked checkpoint records its origin in two places:

  • metadata.forkedFrom: { sessionId, checkpointId } — the semantic lineage edge. Audit-ledger queries join against this to walk forked sessions back to their origin. The forked session keeps its own audit rows under the new sessionId; forkedFrom on the first checkpoint is the one place the cross-session relationship is recorded.
  • parent_id: source.id on the raw checkpoint row (surfaced as metadata.parents: [source.id] on the snapshot). This is a cross-session parent edge — distinct from forkedFrom only by shape (a parent id vs. a {sessionId, checkpointId} pair). Same-session supersteps already set parent_id to the previous checkpoint; fork now sets it to the source checkpoint so traversals walking metadata.parents see the origin instead of treating forked snapshots as orphan roots.

Event-log cursor carry

The fork inherits metadata.lastEventSequence from the source checkpoint. A consumer hydrating the forked session passes that cursor to runtime.events.iterate({ chatId, fromSequenceNumber }) exactly as it would for the source — the cursor is the join point between snapshot state and the event log.

Materializing the forked session row

TimeTravelAPI accepts an optional createForkedSession hook on its constructor options. After the forked checkpoint lands, the API invokes the hook so the host can materialize a Session record at newSessionId — typically by cloning the source session's SessionConfig. Without the hook, a subsequent runtime.sessions.resume(newSessionId) will throw on a missing session row; legacy callers must materialize the row themselves before resume.

The hook is best-effort: a thrown error is caught and logged via console.warn, and the fork's checkpoint write is not rolled back. The checkpoint is the durable artifact; the session row is a derived convenience.

Walking history

for await (const snapshot of api.getStateHistory(sessionId, { limit: 50 })) {
  console.log(snapshot.metadata.step, snapshot.metadata.source, snapshot.config.checkpointId);
}

Pass source: "tool-loop" to filter to checkpoints written by the tool-loop stage; pass before: "cp_..." to page backwards from a known checkpoint id.

Replaying synthetic supersteps

bulkUpdateState is how a test harness or migration script writes a sequence of channel updates without running the graph. Each superstep is an array of writes; the API replays them through createChannels() so reducers fire normally, then persists a single source: "bulk_update" checkpoint at the end.

const snapshot = await api.bulkUpdateState(sessionId, [
  // Superstep 1 — seed the corpus channel.
  [{ channel: "search_results", value: { docs: [{ id: "doc_001" }] } }],
  // Superstep 2 — append a summary.
  [{ channel: "summary", value: "Initial pass complete." }],
]);

snapshot.metadata.source;            // "bulk_update"
snapshot.channelVersions.summary;    // bumped once per superstep that touched it

bulkUpdateState throws if the target session has no prior checkpoint — it needs an anchor to write parent_id against. Use fork for the first checkpoint in a new session id.

Side effects don't roll back

A snapshot captures channel state, not external writes. If a tool called between the target snapshot and now wrote to a database, sent an email, or charged a card, rolling back or forking from the earlier snapshot does not unwind those writes. Tools that need true rollback have to implement compensating actions themselves — Pleach doesn't have a transactional surface across third-party systems.

Where to go next

On this page