| name | session-branch-fork |
| description | Design a session-level fork / revert / recover / suspend mechanism over a paginated history with lineage tracking, immutable segments, global lock, ModelContext reconstruction, and bounded replay.
USE WHEN: designing session persistence, building a "fork this conversation" feature, building "undo last N turns", building "suspend and resume later", implementing a paginated history with segment-level cursor, reconstructing ModelContext from disk, or any task involving "session as a git-like object graph".
TRIGGER PHRASES: "session fork", "session branch", "thread fork", "thread rollback", "revert thread", "ThreadRollback", "SuspendTurnAndShutdown", "Op::RecoverTurn", "paginated history", "RolloutLineage", "ForkBoundary", "RolloutReferenceIndex", "ModelContext reconstruction", "ReverseJsonlScanner", "bounded replay", "git baseline", "writer lock", "subagent lineage".
SKIP WHEN: single-session task state (use `world-state-tracking`), no need to undo, no need to fork.
|
| license | Apache-2.0 |
| compatibility | Targets MiniMax Code 0.2.4. **Conceptual reference only.** The Rust types (`ThreadHistoryMode` / `RolloutLineageSegment` / `RolloutLineage` / `ForkBoundary` / `PrepareForkParams` / `PreparedFork` / `RevertThreadParams` / `StoredModelContext` / `UpdateProjectParams` / `ReverseJsonlScanner` / `ModelContextScan`), the SQLite CAS on `rollout_path`, the `writer_lock_coordinator`, the `try_claim_global_phase2_job` / `heartbeat_global_phase2_job` lease, the `Op::SuspendTurnAndShutdown` / `Op::RecoverTurn` ops, the `JOB_LEASE_SECONDS` / `GRACEFULL_INTERRUPTION_TIMEOUT_MS` / `MAX_ROLLOUT_LINE_BYTES` constants, the `TaskKind::Regular` / `Feature::Collab` / `PermissionProfile::External` enums, and the `expected_sqlite_path` anchor are all **Codex-internal APIs** (`codex-rs/thread-store/`, `codex-rs/state/src/runtime/recovery.rs`, `codex-rs/state/migrations/0047_rollout_migration_state.sql`). They are **not** part of the mcode 0.2.4 public surface. The mcode 0.2.4 `task` tool returns a `task_id` (string); anything else (`thread_id`, `rollout_id`, sqlite path, lineage segment, ModelContext reconstruction algorithm) is **host-internal**. The Skills below present the Codex reference as conceptual pseudocode; the actual implementation must follow the host's verified APIs. |
| metadata | {"author":"antianqi","version":"0.1.1","inspired-by":"https://github.com/openai/codex/tree/main/codex-rs/thread-store/ (P-49/50/51/52 + P-67-77)","changes-from-v0.1.0":"v1.0.5 amendment (PR #33 round-12, hetaoBackend CHANGES_REQUESTED on head 5a4e3fc): the previous body presented the Codex `codex-rs/thread-store/` Rust types and SQLite / `writer_lock_coordinator` / `Op::SuspendTurnAndShutdown` surface as if it were an mcode 0.2.4 design. None of those types are on the mcode public surface; the mcode call shape for sub-agent lifecycle is `task(...)` returning a `task_id` (string). The `compatibility` frontmatter field now states the conceptual-reference nature of the Skill explicitly, and a banner above the Process section reiterates that every Rust block, SQLite CAS anchor, lease helper, and op name is Codex-internal pseudocode."} |
Session Branch / Fork
Conceptual reference only. The mcode 0.2.4 public surface has no
parameters for session fork / revert / suspend / paginated history /
ModelContext reconstruction. The only documented mcode 0.2.4 tool for
sub-agent lifecycle is task(...), which returns a task_id (string);
any deeper structure (thread_id, rollout_id, sqlite path, lineage
segment, ModelContext reconstruction algorithm) is host-internal.
Every Rust block, SQLite CAS anchor, lease helper, and op name below is
Codex-internal pseudocode (codex-rs/thread-store/), NOT a mcode
0.2.4 design. The actual implementation must follow the host's verified
APIs.
Design a session-level fork / revert / recover / suspend system over a paginated
history. Mirrors codex-rs/thread-store/.
When to use
Activate when designing:
- A "fork this session from turn N" feature.
- A "undo the last N turns" feature.
- A "suspend the running session, recover it later" feature.
- A paginated history with cross-segment cursors.
- A model-context reconstruction algorithm that doesn't re-read the entire history.
When NOT to use
- Single-session task state → use
world-state-tracking.
- No undo, no fork, no suspend needed → standard append-only history is enough.
Process
1. Pick a ThreadHistoryMode
pub enum ThreadHistoryMode {
Legacy,
Paginated,
}
Default to Legacy for backward compatibility, but use Paginated for any new
thread. Paginated is what makes fork / revert / lineage work.
2. Define the immutable segment model
pub struct RolloutLineageSegment {
pub rollout_id: ThreadId,
pub rollout_path: PathBuf,
pub start_ordinal: u64,
pub end: Option<HistoryPosition>,
}
pub struct RolloutLineage {
pub segments: Vec<RolloutLineageSegment>,
}
Key invariants:
- Segments are immutable. A new segment is created for any change.
- The lineage is a list of segments ordered from oldest to newest.
- Each segment knows its start ordinal and (optionally) its end offset.
3. Implement fork with ForkBoundary
pub enum ForkBoundary {
Latest,
ThroughTurn(String),
BeforeTurn(String),
}
pub struct PrepareForkParams {
pub thread_id: ThreadId,
pub boundary: ForkBoundary,
}
pub struct PreparedFork {
pub source_thread_id: ThreadId,
pub model_context: Arc<ModelContext>,
}
Fork flow:
- Lock the source thread's lifecycle + writer.
- Persist any pending items in the source.
- Resolve the source's
RolloutLineage.
- Materialize each ancestor segment to SQLite (if not already).
- Load the
ModelContext for the chosen boundary.
- Return a
PreparedFork ready to be turned into a new thread.
4. Implement revert with CAS
pub struct RevertThreadParams {
pub thread_id: ThreadId,
pub before_turn_id: String,
}
Revert flow:
- Lock lifecycle + writer + writer_lock_coordinator.
- Resolve the current rollout from SQLite (
expected_sqlite_path is the CAS anchor).
- Read
SessionMeta from the current rollout, verify id == thread_id and history_mode == Paginated.
- Materialize any compressed lineage segments.
- Create a new immutable rollout file referencing the retained prefix.
- CAS the SQLite
rollout_path to the new file.
Critical contract: revert only rolls back in-memory context. It does not undo filesystem changes. The client is responsible for undoing edits on disk.
5. Implement suspend + recover
Op::SuspendTurnAndShutdown { reply: oneshot::Sender<...> }
Op::RecoverTurn { thread_settings, reply: oneshot::Sender<...> }
Suspend flow (9 steps):
- Lock
active_turn and verify task.kind == TaskKind::Regular.
- Snapshot descendants (not a seal; best-effort).
live_thread.flush() — persistence first; if it fails, leave the turn running.
- Re-lock and re-verify kind (flush can yield).
- Take the turn and task; cancel the cancellation token; cancel git enrichment.
task.handle.detach() with a GRACEFULL_INTERRUPTION_TIMEOUT_MS timeout.
session.input_queue.clear_pending(&turn) — pending input is NOT persisted.
shutdown_session_runtime(session) + live_thread.flush() + live_thread.shutdown().
- Emit
ShutdownComplete event only after the writer is closed.
Critical contract: do NOT record a terminal turn event on suspend. This
intentionally leaves the turn's ID reclaimable, so Op::RecoverTurn can resume it.
6. Use a global lock for multi-process safety
When two Codexes might fork / revert the same thread, use a DB-level lease:
try_claim_global_phase2_job(thread_id, JOB_LEASE_SECONDS) — single-writer.
heartbeat_global_phase2_job(ownership_token, JOB_LEASE_SECONDS) — keep lease alive.
- On agent completion, re-verify ownership BEFORE
reset_git_repository to avoid
resetting someone else's work.
7. Reconstruct ModelContext with bounded replay
pub fn load_latest_model_context(store, params) -> StoredModelContext {
let path = thread_rollout_resolver::resolve_current(...).await?;
let session_meta = read_session_meta_line(path).await?;
if session_meta.id != params.thread_id { return Err(InvalidRequest); }
let mut scanner = ReverseJsonlScanner::new(file)?
.with_max_record_bytes(MAX_ROLLOUT_LINE_BYTES);
let mut scan = ModelContextScan::default();
while let Some(outcome) = scanner.scan_next::<Value>()? {
if let ScanOutcome::Parsed(value) = outcome {
if scan.push(line) == ModelContextScanProgress::Complete {
let items = scan.finish(session_meta);
items.retain(|i| !matches!(i, RolloutItem::SessionMeta(_)));
return Ok(StoredModelContext { items });
}
}
}
}
Three guarantees:
ReverseJsonlScanner reads from the END of the file — only the suffix is touched.
MAX_ROLLOUT_LINE_BYTES prevents a single bad line from OOM-ing the reader.
ScanOutcome::Rejected skips malformed lines; never aborts the whole read.
- If no bounded cutoff is found, fall back to the full replay (read entire file).
8. Run a Legacy → Paginated migration on startup
pub async fn migrate_rollouts_on_startup(store) {
}
9. Use a writer lock for serialization
writer_lock_coordinator.acquire(thread_id)? — guarantees that writes to a
single thread are serialized even across processes. Drop the lock when the
thread is closed.
10. Adopt the 3-state section / project model
pub struct UpdateProjectParams {
pub project_id: String,
pub name: Option<String>,
pub roots: Option<Vec<StoredProjectRoot>>,
pub metadata: Option<BTreeMap<String, String>>,
}
Use Option<Option<T>> for 3-state: None = no change, Some(None) = clear,
Some(Some(v)) = set.
Output contract
A session-management system that follows this design:
- Threads have a
ThreadHistoryMode (Legacy / Paginated).
- Paginated threads have a
RolloutLineage of immutable segments.
- Fork accepts a
ForkBoundary and returns a PreparedFork.
- Revert uses CAS on the SQLite rollout_path; creates a new immutable segment.
- Suspend + Recover are paired; suspend does NOT record a terminal event.
- Reconstruct ModelContext via reverse scan with bounded replay + byte cap + Rejected skip.
- Migration runs at startup with cursor in DB + fingerprint skip + 48h lookback.
- Multi-process safety via DB leases and writer lock coordinator.
Common pitfalls
- No
Paginated history mode → no fork / revert. New threads should default to Paginated.
- Revert undoes filesystem → it does not. Client is responsible.
- Suspend records terminal event → Recover can never resume. Don't.
- Pending input persisted → replay state is wrong.
clear_pending on suspend.
- Reverse scan reads whole file → slow. Bounded replay +
MAX_ROLLOUT_LINE_BYTES.
- Migration on every startup reads all rollouts → slow. Cursor + 48h lookback.
- Two Codexes reverting simultaneously → corruption. Lease / writer lock.
- Reset git baseline with diff present → deleted content stays in git objects. Delete diff first.
- Single-type partial update vs. clear → use
Option<Option<T>> for 3-state.
Example — fork from a turn
# Source thread has 50 turns
fork_boundary = BeforeTurn("turn-30")
prepare_fork(thread_id, boundary) →
1. Lock source lifecycle + writer
2. Persist pending items
3. Resolve lineage (5 segments)
4. Materialize segments 1..4 to SQLite
5. ModelContext = base + turns 1..29 (before turn-30)
6. PreparedFork { source_thread_id, model_context }
new_thread = create_thread(forked_from_id = source.id, history_base = ModelContext.snapshot)
# New thread starts at turn 30, inherits turns 1..29 from ModelContext
Verification checklist