Skip to main content

ralph/commands/task/decompose/
write.rs

1//! Queue-write orchestration for task decomposition.
2//!
3//! Responsibilities:
4//! - Re-validate queue state under lock before persisting decomposition output.
5//! - Enforce child-policy semantics and create undo snapshots for durable writes.
6//! - Materialize normalized planner trees into queue tasks with stable insertion order.
7//!
8//! Not handled here:
9//! - Planner execution, prompt rendering, or response parsing.
10//! - Tree normalization algorithms or low-level helper implementations.
11//!
12//! Invariants/assumptions:
13//! - Writes must fail fast when preview blockers remain unresolved.
14//! - Queue validation runs both before and after mutation.
15
16use super::resolve::resolve_effective_parent_for_write;
17use super::support::{
18    allocate_sequential_ids, annotate_parent, created_node_count, descendant_ids_for_parent,
19    done_queue_ref, ensure_subtree_is_replaceable, insertion_index, materialize_children,
20    materialize_node, request_context,
21};
22use super::types::{
23    DecompositionChildPolicy, DecompositionPreview, DecompositionSource, TaskDecomposeWriteResult,
24};
25use crate::contracts::QueueFile;
26use crate::{config, queue, timeutil};
27use anyhow::{Context, Result, bail};
28
29pub fn write_task_decomposition(
30    resolved: &config::Resolved,
31    preview: &DecompositionPreview,
32    force: bool,
33) -> Result<TaskDecomposeWriteResult> {
34    if !preview.write_blockers.is_empty() {
35        bail!(preview.write_blockers.join("\n"));
36    }
37
38    let _queue_lock = queue::acquire_queue_lock(&resolved.repo_root, "task decompose", force)?;
39    let mut active = queue::load_queue(&resolved.queue_path)?;
40    let done = queue::load_queue_or_default(&resolved.done_path)?;
41    let done_ref = done_queue_ref(&done, &resolved.done_path);
42    let max_depth = resolved.config.queue.max_dependency_depth.unwrap_or(10);
43    validate_queue_set(&active, done_ref, resolved, max_depth)
44        .context("validate queue set before task decompose write")?;
45
46    let effective_parent = resolve_effective_parent_for_write(&active, done_ref, preview)?;
47    let existing_descendant_ids = effective_parent
48        .as_ref()
49        .map(|task| descendant_ids_for_parent(&active, task.id.as_str()))
50        .transpose()?
51        .unwrap_or_default();
52
53    enforce_child_policy(
54        preview,
55        effective_parent.as_ref(),
56        &active,
57        done_ref,
58        &existing_descendant_ids,
59    )?;
60    crate::undo::create_undo_snapshot(resolved, &undo_snapshot_label(preview))?;
61
62    let created_count = created_node_count(preview);
63    if created_count == 0 {
64        bail!("Task decomposition produced no child tasks to write.");
65    }
66
67    let ids = allocate_sequential_ids(
68        &active,
69        done_ref,
70        &resolved.id_prefix,
71        resolved.id_width,
72        max_depth,
73        created_count,
74    )?;
75    let now = timeutil::now_utc_rfc3339()?;
76    let request_context = request_context(preview);
77    let mut next_id_index = 0usize;
78    let mut created_tasks = materialize_created_tasks(
79        preview,
80        effective_parent.as_ref(),
81        &ids,
82        &mut next_id_index,
83        &request_context,
84        &now,
85    )?;
86
87    let root_task_id = match (&preview.source, preview.attach_target.as_ref()) {
88        (DecompositionSource::ExistingTask { .. }, None) => None,
89        _ => created_tasks.first().map(|task| task.id.clone()),
90    };
91    let parent_task_id = effective_parent.as_ref().map(|task| task.id.clone());
92    let created_ids = created_tasks
93        .iter()
94        .map(|task| task.id.clone())
95        .collect::<Vec<_>>();
96    let replaced_ids = if preview.child_policy == DecompositionChildPolicy::Replace {
97        existing_descendant_ids.iter().cloned().collect::<Vec<_>>()
98    } else {
99        Vec::new()
100    };
101
102    let removed_ids = existing_descendant_ids;
103    if !removed_ids.is_empty() && preview.child_policy == DecompositionChildPolicy::Replace {
104        active
105            .tasks
106            .retain(|task| !removed_ids.contains(task.id.as_str()));
107    }
108
109    let insert_at = insertion_index(
110        &active,
111        effective_parent.as_ref(),
112        &removed_ids,
113        preview.child_policy,
114    )?;
115
116    if let Some(parent) = effective_parent {
117        annotate_parent(
118            &mut active,
119            &parent.id,
120            &preview.source,
121            preview.attach_target.as_ref(),
122            &created_tasks,
123            &now,
124        )?;
125    }
126
127    for (offset, task) in created_tasks.drain(..).enumerate() {
128        active.tasks.insert(insert_at + offset, task);
129    }
130
131    validate_queue_set(&active, done_ref, resolved, max_depth)
132        .context("validate queue set after task decompose write")?;
133    queue::save_queue(&resolved.queue_path, &active)?;
134
135    Ok(TaskDecomposeWriteResult {
136        root_task_id,
137        parent_task_id,
138        created_ids,
139        replaced_ids,
140        parent_annotated: preview.attach_target.is_some()
141            || matches!(preview.source, DecompositionSource::ExistingTask { .. }),
142    })
143}
144
145fn validate_queue_set(
146    active: &QueueFile,
147    done_ref: Option<&QueueFile>,
148    resolved: &config::Resolved,
149    max_depth: u8,
150) -> Result<()> {
151    queue::validate_queue_set(
152        active,
153        done_ref,
154        &resolved.id_prefix,
155        resolved.id_width,
156        max_depth,
157    )
158    .map(|_| ())
159}
160
161fn enforce_child_policy(
162    preview: &DecompositionPreview,
163    effective_parent: Option<&crate::contracts::Task>,
164    active: &QueueFile,
165    done_ref: Option<&QueueFile>,
166    existing_descendant_ids: &std::collections::HashSet<String>,
167) -> Result<()> {
168    match preview.child_policy {
169        DecompositionChildPolicy::Fail => {
170            if !existing_descendant_ids.is_empty() {
171                let parent_id = effective_parent
172                    .as_ref()
173                    .map(|task| task.id.as_str())
174                    .unwrap_or("");
175                bail!(
176                    "Task {} already has child tasks. Refusing write for `ralph task decompose --child-policy fail`.",
177                    parent_id
178                );
179            }
180        }
181        DecompositionChildPolicy::Replace => {
182            if !existing_descendant_ids.is_empty() {
183                ensure_subtree_is_replaceable(active, done_ref, existing_descendant_ids)?;
184            }
185        }
186        DecompositionChildPolicy::Append => {}
187    }
188    Ok(())
189}
190
191fn materialize_created_tasks(
192    preview: &DecompositionPreview,
193    effective_parent: Option<&crate::contracts::Task>,
194    ids: &[String],
195    next_id_index: &mut usize,
196    request_context: &str,
197    now: &str,
198) -> Result<Vec<crate::contracts::Task>> {
199    match (&preview.source, effective_parent) {
200        (DecompositionSource::ExistingTask { .. }, Some(parent))
201            if preview.attach_target.is_none() =>
202        {
203            materialize_children(
204                &preview.plan.root.children,
205                Some(parent.id.as_str()),
206                ids,
207                next_id_index,
208                preview.child_status,
209                request_context,
210                now,
211            )
212        }
213        (_, Some(parent)) => {
214            let root_task = materialize_node(
215                &preview.plan.root,
216                Some(parent.id.as_str()),
217                ids,
218                next_id_index,
219                preview.child_status,
220                request_context,
221                now,
222            )?;
223            let root_id = root_task.id.clone();
224            let mut tasks = vec![root_task];
225            tasks.extend(materialize_children(
226                &preview.plan.root.children,
227                Some(root_id.as_str()),
228                ids,
229                next_id_index,
230                preview.child_status,
231                request_context,
232                now,
233            )?);
234            Ok(tasks)
235        }
236        (_, None) => {
237            let root_task = materialize_node(
238                &preview.plan.root,
239                None,
240                ids,
241                next_id_index,
242                preview.child_status,
243                request_context,
244                now,
245            )?;
246            let root_id = root_task.id.clone();
247            let mut tasks = vec![root_task];
248            tasks.extend(materialize_children(
249                &preview.plan.root.children,
250                Some(root_id.as_str()),
251                ids,
252                next_id_index,
253                preview.child_status,
254                request_context,
255                now,
256            )?);
257            Ok(tasks)
258        }
259    }
260}
261
262fn undo_snapshot_label(preview: &DecompositionPreview) -> String {
263    match (&preview.source, preview.attach_target.as_ref()) {
264        (DecompositionSource::Freeform { request }, None) => {
265            format!("task decompose write for request '{request}'")
266        }
267        (DecompositionSource::Freeform { request }, Some(parent)) => {
268            format!(
269                "task decompose attach request '{}' under {}",
270                request, parent.task.id
271            )
272        }
273        (DecompositionSource::ExistingTask { task }, None) => {
274            format!("task decompose {} into child tasks", task.id)
275        }
276        (DecompositionSource::ExistingTask { task }, Some(parent)) => {
277            format!(
278                "task decompose {} attached under {}",
279                task.id, parent.task.id
280            )
281        }
282    }
283}