- 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`
```rust
pub enum ThreadHistoryMode {
Legacy, // entire thread in one JSONL
Paginated, // immutable segments, supports fork/revert
}
```
**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
```rust
pub struct RolloutLineageSegment {
pub rollout_id: ThreadId,
pub rollout_path: PathBuf,
pub start_ordinal: u64,
pub end: Option<HistoryPosition>, // byte offset
}
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`
```rust
pub enum ForkBoundary {
Latest, // inherit source's latest durable state
ThroughTurn(String), // include this turn
BeforeTurn(String), // exclude this turn
}
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:
1. Lock the source thread's lifecycle + writer.
2. Persist any pending items in the source.
3. Resolve the source's `RolloutLineage`.
4. Materialize each ancestor segment to SQLite (if not already).
5. Load the `ModelContext` for the chosen boundary.
6. Return a `PreparedFork` ready to be turned into a new thread.
### 4. Implement revert with CAS
```rust
pub struct RevertThreadParams {
pub thread_id: ThreadId,
pub before_turn_id: String, // first turn EXCLUDED from retained history
}
```
Revert flow:
1. Lock lifecycle + writer + writer_lock_coordinator.
2. Resolve the current rollout from SQLite (`expected_sqlite_path` is the CAS anchor).
3. Read `SessionMeta` from the current rollout, verify `id == thread_id` and `history_mode == Paginated`.
4. Materialize any compressed lineage segments.
5. Create a new immutable rollout file referencing the retained prefix.
6. 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
```rust
Op::SuspendTurnAndShutdown { reply: oneshot::Sender<...> }
Op::RecoverTurn { thread_settings, reply: oneshot::Sender<...> }
```
Suspend flow (9 steps):
1. Lock `active_turn` and verify `task.kind == TaskKind::Regular`.
2. Snapshot descendants (not a seal; best-effort).
3. `live_thread.flush()` — persistence first; if it fails, leave the turn running.
4. Re-lock and re-verify kind (flush can yield).
5. Take the turn and task; cancel the cancellation token; cancel git enrichment.
6. `task.handle.detach()` with a `GRACEFULL_INTERRUPTION_TIMEOUT_MS` timeout.
7. `session.input_queue.clear_pending(&turn)` — pending input is NOT persisted.
8. `shutdown_session_runtime(session)` + `live_thread.flush()` + `live_thread.shutdown()`.
9. 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
```rust
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 });
}
}
// ScanOutcome::Rejected — skip bad line, do not abort
}
// No bounded cutoff found — fall back to full replay
}
```
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
```rust
pub async fn migrate_rollouts_on_startup(store) {
// Use a creation-ordered cursor in SQLite to check only newer rollout files
// 48h lookback window catches files we skipped earlier
// Fingerprint (size_bytes + modified_at_ns) skips empty/malformed rollouts
// Use run marker to prevent overlapping migrations
// Spawn subagent-aware bounded replay for subagent rollouts (don't copy
// the parent's full history into every child)
}
```
### 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
```rust
pub struct UpdateProjectParams {
pub project_id: String,
pub name: Option<String>, // None = no change
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
```text
# 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
- [ ] All new threads default to `ThreadHistoryMode::Paginated`.
- [ ] Fork accepts `ForkBoundary` (Latest / ThroughTurn / BeforeTurn).
- [ ] Revert uses CAS on the SQLite rollout path; creates a new immutable segment.
- [ ] Suspend flushes BEFORE canceling, and re-checks turn kind after flush.
- [ ] Suspend does NOT record a terminal turn event.
- [ ] Suspend clears pending input (input is not persisted).
- [ ] Suspend emits `ShutdownComplete` ONLY after `live_thread.shutdown()` returns.
- [ ] Reconstruct ModelContext via `ReverseJsonlScanner` with byte cap.
- [ ] Reconstruct skips `ScanOutcome::Rejected` without aborting.
- [ ] Migration uses SQLite cursor + fingerprint skip + 48h lookback.
- [ ] Multi-process safety: writer lock + DB lease + ownership token.
- [ ] Partial-update APIs use `Option<Option<T>>` for 3-state.
Auf GitHub ansehen