Skip to main content

roder_core/
conversation_forks.rs

1//! Conversation forks (roadmap phases 90 + 81).
2//!
3//! Forks an existing thread into a child thread backed by a workspace fork
4//! from any registered `ForkProvider` (default: `git-worktree`): the child
5//! starts from the parent transcript (no side-effectful tool replay — only
6//! conversation history records are copied) and all subsequent tool
7//! execution resolves against the fork workspace because the child's
8//! `ThreadMetadata.workspace` points at it. Cleanup is explicit and
9//! path-confirmed; the parent workspace is never modified.
10
11use std::path::PathBuf;
12use std::sync::Arc;
13
14use roder_api::events::{
15    EventEnvelope, RoderEvent, ThreadCreated, ThreadForkFailed, ThreadForkRemoved,
16    ThreadForkRequested, ThreadForked, ThreadId, TurnId,
17};
18use roder_api::forks::{
19    ForkPolicy, ForkReason, ForkRequest, ForkStatus, RemoveForkPolicy, WorkspaceFork,
20};
21use roder_api::thread::{ThreadMetadata, ThreadStore};
22use time::OffsetDateTime;
23
24use crate::Runtime;
25use crate::forks::DEFAULT_FORK_PROVIDER;
26
27mod history;
28use history::seed_events_for_child;
29
30#[derive(Debug, Clone)]
31pub struct ForkThreadRequest {
32    pub parent_thread_id: ThreadId,
33    /// User-facing fork name; the provider sanitizes it into its naming
34    /// scheme (directories, branches, snapshot names).
35    pub name: String,
36    /// Fork at a specific parent turn; `None` forks at the latest turn.
37    pub from_turn_id: Option<TurnId>,
38    /// Fork provider id; `None` uses [`DEFAULT_FORK_PROVIDER`].
39    pub provider_id: Option<String>,
40    /// Provider-specific options (never secrets).
41    pub provider_config: serde_json::Value,
42}
43
44impl ForkThreadRequest {
45    pub fn new(parent_thread_id: ThreadId, name: impl Into<String>) -> Self {
46        Self {
47            parent_thread_id,
48            name: name.into(),
49            from_turn_id: None,
50            provider_id: None,
51            provider_config: serde_json::json!({}),
52        }
53    }
54}
55
56#[derive(Debug, Clone)]
57pub struct ForkThreadOutcome {
58    pub child: ThreadMetadata,
59    pub warnings: Vec<String>,
60}
61
62impl Runtime {
63    /// Seeds a long-lived collaboration agent with a safe subset of the parent
64    /// conversation. Unlike `fork_thread`, this keeps the same workspace: only
65    /// transcript/lifecycle records are copied, never executable tool or approval
66    /// events.
67    pub(crate) async fn seed_agent_thread_history(
68        &self,
69        parent_thread_id: &ThreadId,
70        child_thread_id: &ThreadId,
71        fork_turns: &str,
72    ) -> anyhow::Result<()> {
73        if fork_turns == "none" {
74            return Ok(());
75        }
76        let Some(store) = self.thread_store.clone() else {
77            return Ok(());
78        };
79        let Some(parent) = store.load_thread(parent_thread_id).await? else {
80            return Ok(());
81        };
82        let mut events = seed_events_for_child(&parent.events, None)?;
83        if fork_turns != "all" {
84            let turn_count = fork_turns.parse::<usize>().map_err(|_| {
85                anyhow::anyhow!("fork_turns must be one of none, all, or a positive integer")
86            })?;
87            anyhow::ensure!(turn_count > 0, "fork_turns integer must be positive");
88            let mut ordered_turns = Vec::<TurnId>::new();
89            for event in &events {
90                if let Some(turn_id) = event.turn_id.as_ref()
91                    && ordered_turns.last() != Some(turn_id)
92                {
93                    ordered_turns.push(turn_id.clone());
94                }
95            }
96            let keep_from = ordered_turns.len().saturating_sub(turn_count);
97            let kept = &ordered_turns[keep_from..];
98            events.retain(|event| {
99                event
100                    .turn_id
101                    .as_ref()
102                    .is_some_and(|turn_id| kept.contains(turn_id))
103            });
104        }
105        for event in &events {
106            store.append_event(child_thread_id, event).await?;
107        }
108        Ok(())
109    }
110
111    /// Forks `parent_thread_id` into a new child thread backed by a fresh
112    /// workspace fork of the parent workspace.
113    pub async fn fork_thread(
114        &self,
115        request: ForkThreadRequest,
116    ) -> anyhow::Result<ForkThreadOutcome> {
117        self.emit(RoderEvent::ThreadForkRequested(ThreadForkRequested {
118            parent_thread_id: request.parent_thread_id.clone(),
119            name: request.name.clone(),
120            timestamp: OffsetDateTime::now_utc(),
121        }))
122        .await;
123        match self.fork_thread_inner(&request).await {
124            Ok(outcome) => Ok(outcome),
125            Err(error) => {
126                self.emit(RoderEvent::ThreadForkFailed(ThreadForkFailed {
127                    parent_thread_id: request.parent_thread_id.clone(),
128                    name: request.name.clone(),
129                    message: error.to_string(),
130                    timestamp: OffsetDateTime::now_utc(),
131                }))
132                .await;
133                Err(error)
134            }
135        }
136    }
137
138    async fn fork_thread_inner(
139        &self,
140        request: &ForkThreadRequest,
141    ) -> anyhow::Result<ForkThreadOutcome> {
142        let store = self
143            .thread_store
144            .clone()
145            .ok_or_else(|| anyhow::anyhow!("conversation forks require a thread store"))?;
146        let parent = store
147            .load_thread(&request.parent_thread_id)
148            .await?
149            .ok_or_else(|| {
150                anyhow::anyhow!("parent thread {} was not found", request.parent_thread_id)
151            })?;
152        let parent_metadata = parent.metadata.clone().ok_or_else(|| {
153            anyhow::anyhow!(
154                "parent thread {} has no metadata to fork from",
155                request.parent_thread_id
156            )
157        })?;
158        // Validate the history boundary before allocating a checkout. The
159        // child identity is recorded atomically with ownership at creation.
160        let seed_events = seed_events_for_child(&parent.events, request.from_turn_id.as_deref())?;
161        let child_id = uuid::Uuid::new_v4().to_string();
162
163        // Materialize the workspace fork first; thread creation only
164        // proceeds once an isolated workspace exists.
165        let provider_id = request
166            .provider_id
167            .clone()
168            .unwrap_or_else(|| DEFAULT_FORK_PROVIDER.to_string());
169        let fork = self
170            .create_workspace_fork(
171                &provider_id,
172                ForkRequest {
173                    ownership: Some(roder_api::forks::ForkOwnership {
174                        creator_thread_id: Some(request.parent_thread_id.clone()),
175                        owner_thread_id: Some(child_id.clone()),
176                    }),
177                    source_workspace: PathBuf::from(&parent_metadata.workspace),
178                    name: Some(request.name.clone()),
179                    reason: ForkReason::ConversationFork,
180                    policy: ForkPolicy::default(),
181                    provider_config: request.provider_config.clone(),
182                },
183            )
184            .await?;
185
186        let now = OffsetDateTime::now_utc();
187        let mut warnings = Vec::new();
188        if request.from_turn_id.is_none() && seed_events.is_empty() && !parent.events.is_empty() {
189            warnings.push(
190                "parent thread has events but none were conversation records; the fork starts \
191                 with an empty transcript"
192                    .to_string(),
193            );
194        }
195
196        let child_metadata = ThreadMetadata {
197            thread_id: child_id.clone(),
198            title: Some(match &parent_metadata.title {
199                Some(title) => format!("{title} (fork: {})", request.name),
200                None => format!("fork: {}", request.name),
201            }),
202            workspace: fork.workspace.display().to_string(),
203            // The fork workspace lives outside registered workspace roots.
204            workspace_id: None,
205            root_id: None,
206            provider: parent_metadata.provider.clone(),
207            model: parent_metadata.model.clone(),
208            selection_mode: parent_metadata.selection_mode.clone(),
209            tool_allowlist: parent_metadata.tool_allowlist.clone(),
210            service_tier: parent_metadata.service_tier.clone(),
211            developer_instructions: parent_metadata.developer_instructions.clone(),
212            external_tools: parent_metadata.external_tools.clone(),
213            // Local workspace forks never inherit runner bindings.
214            runner_destination: None,
215            runner_state: None,
216            runner_binding: None,
217            created_at: now,
218            updated_at: now,
219            message_count: 0,
220            usage: None,
221            parent_thread_id: Some(request.parent_thread_id.clone()),
222            forked_from_turn_id: request.from_turn_id.clone(),
223            workspace_fork: Some(fork.clone()),
224        };
225
226        let seed = async {
227            self.seed_child_thread(&store, child_metadata.clone(), &child_id, seed_events)
228                .await?;
229            self.goals
230                .inherit_thread_goal_snapshot(&request.parent_thread_id, &child_id)
231                .await
232        }
233        .await;
234        let inherited_goal = match seed {
235            Ok(goal) => goal,
236            Err(error) => {
237                // Best-effort cleanup so a failed fork does not leak a workspace.
238                let _ = self
239                    .remove_workspace_fork(
240                        &provider_id,
241                        &fork.id,
242                        RemoveForkPolicy {
243                            confirm_workspace: fork.workspace.clone(),
244                        },
245                    )
246                    .await;
247                return Err(error);
248            }
249        };
250
251        self.emit(RoderEvent::ThreadCreated(ThreadCreated {
252            thread_id: child_id.clone(),
253            timestamp: OffsetDateTime::now_utc(),
254        }))
255        .await;
256        if let Some(goal) = inherited_goal {
257            self.goals.emit_goal_updated(goal).await;
258        }
259        self.emit(RoderEvent::ThreadForked(ThreadForked {
260            parent_thread_id: request.parent_thread_id.clone(),
261            child_thread_id: child_id.clone(),
262            fork,
263            timestamp: OffsetDateTime::now_utc(),
264        }))
265        .await;
266
267        let child = store
268            .load_thread_metadata(&child_id)
269            .await?
270            .unwrap_or(child_metadata);
271        Ok(ForkThreadOutcome { child, warnings })
272    }
273
274    async fn seed_child_thread(
275        &self,
276        store: &Arc<dyn ThreadStore>,
277        child_metadata: ThreadMetadata,
278        child_id: &ThreadId,
279        seed_events: Vec<EventEnvelope>,
280    ) -> anyhow::Result<()> {
281        store.create_thread(child_metadata).await?;
282        for envelope in &seed_events {
283            store.append_event(child_id, envelope).await?;
284        }
285        Ok(())
286    }
287
288    /**
289     * Removes the workspace fork behind a forked thread. Destructive and
290     * explicit: `confirm_path` must match the fork workspace exactly. The
291     * thread itself is kept (status flips to `Removed`) so the conversation
292     * stays readable.
293     */
294    pub async fn remove_thread_workspace_fork(
295        &self,
296        thread_id: &ThreadId,
297        confirm_path: &str,
298    ) -> anyhow::Result<WorkspaceFork> {
299        let store = self
300            .thread_store
301            .clone()
302            .ok_or_else(|| anyhow::anyhow!("conversation forks require a thread store"))?;
303        let mut metadata = store
304            .load_thread_metadata(thread_id)
305            .await?
306            .ok_or_else(|| anyhow::anyhow!("thread {thread_id} was not found"))?;
307        let mut fork = metadata
308            .workspace_fork
309            .clone()
310            .ok_or_else(|| anyhow::anyhow!("thread {thread_id} is not a workspace fork"))?;
311        anyhow::ensure!(
312            fork.status == ForkStatus::Active,
313            "fork {} was already removed",
314            fork.id
315        );
316        anyhow::ensure!(
317            std::path::Path::new(confirm_path) == fork.workspace,
318            "confirmation path does not match the fork workspace {}; removal is \
319             path-confirmed to prevent accidental deletion",
320            fork.workspace.display()
321        );
322
323        self.remove_workspace_fork(
324            &fork.provider_id.clone(),
325            &fork.id.clone(),
326            RemoveForkPolicy {
327                confirm_workspace: fork.workspace.clone(),
328            },
329        )
330        .await?;
331
332        fork.status = ForkStatus::Removed;
333        metadata.workspace_fork = Some(fork.clone());
334        metadata.updated_at = OffsetDateTime::now_utc();
335        store.update_thread_metadata(metadata).await?;
336
337        self.emit(RoderEvent::ThreadForkRemoved(ThreadForkRemoved {
338            thread_id: thread_id.clone(),
339            fork_id: fork.id.clone(),
340            worktree_path: fork.workspace.display().to_string(),
341            timestamp: OffsetDateTime::now_utc(),
342        }))
343        .await;
344        Ok(fork)
345    }
346}