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
159        // Materialize the workspace fork first; thread creation only
160        // proceeds once an isolated workspace exists.
161        let provider_id = request
162            .provider_id
163            .clone()
164            .unwrap_or_else(|| DEFAULT_FORK_PROVIDER.to_string());
165        let fork = self
166            .create_workspace_fork(
167                &provider_id,
168                ForkRequest {
169                    source_workspace: PathBuf::from(&parent_metadata.workspace),
170                    name: Some(request.name.clone()),
171                    reason: ForkReason::ConversationFork,
172                    policy: ForkPolicy::default(),
173                    provider_config: request.provider_config.clone(),
174                },
175            )
176            .await?;
177
178        let now = OffsetDateTime::now_utc();
179        let seed_events = seed_events_for_child(&parent.events, request.from_turn_id.as_deref())?;
180        let mut warnings = Vec::new();
181        if request.from_turn_id.is_none() && seed_events.is_empty() && !parent.events.is_empty() {
182            warnings.push(
183                "parent thread has events but none were conversation records; the fork starts \
184                 with an empty transcript"
185                    .to_string(),
186            );
187        }
188
189        let child_id = uuid::Uuid::new_v4().to_string();
190        let child_metadata = ThreadMetadata {
191            thread_id: child_id.clone(),
192            title: Some(match &parent_metadata.title {
193                Some(title) => format!("{title} (fork: {})", request.name),
194                None => format!("fork: {}", request.name),
195            }),
196            workspace: fork.workspace.display().to_string(),
197            // The fork workspace lives outside registered workspace roots.
198            workspace_id: None,
199            root_id: None,
200            provider: parent_metadata.provider.clone(),
201            model: parent_metadata.model.clone(),
202            selection_mode: parent_metadata.selection_mode.clone(),
203            tool_allowlist: parent_metadata.tool_allowlist.clone(),
204            service_tier: parent_metadata.service_tier.clone(),
205            developer_instructions: parent_metadata.developer_instructions.clone(),
206            external_tools: parent_metadata.external_tools.clone(),
207            // Local workspace forks never inherit runner bindings.
208            runner_destination: None,
209            runner_state: None,
210            runner_binding: None,
211            created_at: now,
212            updated_at: now,
213            message_count: 0,
214            usage: None,
215            parent_thread_id: Some(request.parent_thread_id.clone()),
216            forked_from_turn_id: request.from_turn_id.clone(),
217            workspace_fork: Some(fork.clone()),
218        };
219
220        let seed = async {
221            self.seed_child_thread(&store, child_metadata.clone(), &child_id, seed_events)
222                .await?;
223            self.goals
224                .inherit_thread_goal_snapshot(&request.parent_thread_id, &child_id)
225                .await
226        }
227        .await;
228        let inherited_goal = match seed {
229            Ok(goal) => goal,
230            Err(error) => {
231                // Best-effort cleanup so a failed fork does not leak a workspace.
232                let _ = self
233                    .remove_workspace_fork(
234                        &provider_id,
235                        &fork.id,
236                        RemoveForkPolicy {
237                            confirm_workspace: fork.workspace.clone(),
238                        },
239                    )
240                    .await;
241                return Err(error);
242            }
243        };
244
245        self.emit(RoderEvent::ThreadCreated(ThreadCreated {
246            thread_id: child_id.clone(),
247            timestamp: OffsetDateTime::now_utc(),
248        }))
249        .await;
250        if let Some(goal) = inherited_goal {
251            self.goals.emit_goal_updated(goal).await;
252        }
253        self.emit(RoderEvent::ThreadForked(ThreadForked {
254            parent_thread_id: request.parent_thread_id.clone(),
255            child_thread_id: child_id.clone(),
256            fork,
257            timestamp: OffsetDateTime::now_utc(),
258        }))
259        .await;
260
261        let child = store
262            .load_thread_metadata(&child_id)
263            .await?
264            .unwrap_or(child_metadata);
265        Ok(ForkThreadOutcome { child, warnings })
266    }
267
268    async fn seed_child_thread(
269        &self,
270        store: &Arc<dyn ThreadStore>,
271        child_metadata: ThreadMetadata,
272        child_id: &ThreadId,
273        seed_events: Vec<EventEnvelope>,
274    ) -> anyhow::Result<()> {
275        store.create_thread(child_metadata).await?;
276        for envelope in &seed_events {
277            store.append_event(child_id, envelope).await?;
278        }
279        Ok(())
280    }
281
282    /**
283     * Removes the workspace fork behind a forked thread. Destructive and
284     * explicit: `confirm_path` must match the fork workspace exactly. The
285     * thread itself is kept (status flips to `Removed`) so the conversation
286     * stays readable.
287     */
288    pub async fn remove_thread_workspace_fork(
289        &self,
290        thread_id: &ThreadId,
291        confirm_path: &str,
292    ) -> anyhow::Result<WorkspaceFork> {
293        let store = self
294            .thread_store
295            .clone()
296            .ok_or_else(|| anyhow::anyhow!("conversation forks require a thread store"))?;
297        let mut metadata = store
298            .load_thread_metadata(thread_id)
299            .await?
300            .ok_or_else(|| anyhow::anyhow!("thread {thread_id} was not found"))?;
301        let mut fork = metadata
302            .workspace_fork
303            .clone()
304            .ok_or_else(|| anyhow::anyhow!("thread {thread_id} is not a workspace fork"))?;
305        anyhow::ensure!(
306            fork.status == ForkStatus::Active,
307            "fork {} was already removed",
308            fork.id
309        );
310        anyhow::ensure!(
311            std::path::Path::new(confirm_path) == fork.workspace,
312            "confirmation path does not match the fork workspace {}; removal is \
313             path-confirmed to prevent accidental deletion",
314            fork.workspace.display()
315        );
316
317        self.remove_workspace_fork(
318            &fork.provider_id.clone(),
319            &fork.id.clone(),
320            RemoveForkPolicy {
321                confirm_workspace: fork.workspace.clone(),
322            },
323        )
324        .await?;
325
326        fork.status = ForkStatus::Removed;
327        metadata.workspace_fork = Some(fork.clone());
328        metadata.updated_at = OffsetDateTime::now_utc();
329        store.update_thread_metadata(metadata).await?;
330
331        self.emit(RoderEvent::ThreadForkRemoved(ThreadForkRemoved {
332            thread_id: thread_id.clone(),
333            fork_id: fork.id.clone(),
334            worktree_path: fork.workspace.display().to_string(),
335            timestamp: OffsetDateTime::now_utc(),
336        }))
337        .await;
338        Ok(fork)
339    }
340}