diff --git a/README.md b/README.md index 95a6ca05..bd777535 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Use `thirdshift help` for the command reference, or read [Ticket run behavior](d | A security audit | `thirdshift secure` | A [Security run](docs/guide.md#security-runs) records findings privately; fixing a reproduced finding is optional. | | Merged factory work | `thirdshift score` | A [Scoring run](docs/guide.md#scoring-runs) samples and grades it, and proposes Bug origins; `thirdshift scores --json` lists samples, five criterion Scores, calibration and proposals by week; `thirdshift scores --mine` records yours before revealing the model's. | | One skill | `thirdshift skill [issue ]` | A [standalone skill](docs/guide.md#standalone-skills) runs headlessly and prints its final message. | -| A Custom process | `thirdshift process ` | An experimental [Custom process](docs/guide.md#custom-processes) runs sequential skill or command Steps from `~/.thirdshift/processes/.toml`. An optional Step can fail without stopping the process. | +| A Custom process | `thirdshift process ` | An experimental [Custom process](docs/guide.md#custom-processes) runs skill or command Steps from `~/.thirdshift/processes/.toml`, in named throwaway worktrees when the file declares them. An optional Step can fail without stopping the process. | Each Architect, Pickup or Security run makes one pass. Use cron or another operating system scheduler to repeat it. See [scheduling](docs/guide.md#on-a-schedule), [Architect runs every few minutes](docs/guide.md#architect-runs-every-few-minutes) and [Security runs on a schedule](docs/guide.md#security-runs-on-a-schedule-with-fixing-allowed). diff --git a/docs/guide.md b/docs/guide.md index 2d44d996..8561cdba 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -830,9 +830,9 @@ Command record, Console entry or Session log is created for a standalone skill. ### Custom processes -A Custom process runs Steps in order. A Step is a skill session or a command. -It is experimental. Write `~/.thirdshift/processes/.toml`. The name is -the file's stem. +A Custom process runs Steps in order, and can run consecutive Steps together. +A Step is a skill session or a command. It is experimental. Write +`~/.thirdshift/processes/.toml`. The name is the file's stem. ```toml version = 1 @@ -852,11 +852,62 @@ command = "false" optional = true ``` +With no `[worktrees]` table, every Step shares one throwaway worktree. To run +Steps at the same time, declare a throwaway worktree for each and give +consecutive Steps the same `parallel` group: + +```toml +version = 1 +description = "Three families, then a judge" + +[worktrees.claude] +kind = "throwaway" + +[worktrees.codex] +kind = "throwaway" + +[worktrees.grok] +kind = "throwaway" + +[worktrees.judge] +kind = "throwaway" + +[[steps]] +name = "claude" +skill = "thirdshift-code-review" +worktree = "claude" +parallel = "families" + +[[steps]] +name = "codex" +skill = "thirdshift-code-review" +worktree = "codex" +parallel = "families" + +[[steps]] +name = "grok" +skill = "thirdshift-code-review" +worktree = "grok" +parallel = "families" +optional = true + +[[steps]] +name = "judge" +skill = "thirdshift-to-spec" +worktree = "judge" +``` + `version` must be `1`. Each Step has a `name` and exactly one kind. `skill` is looked up the same way as `thirdshift skill`. `command` is a command or script, -run with `sh -c` in the Step's worktree. It starts no session. `optional = true` -records that Step's failure and the Process carries on. Without `optional`, or -with `optional = false`, a failure stops the Process. +run with `sh -c` in the Step's worktree. It starts no session. A declared +worktree's `kind` must be `throwaway`: it is detached at the Base branch. When +the Process declares worktrees, every Step names one with `worktree`. +Consecutive Steps with the same `parallel` name run at the same time. Every +session or command Step edits the worktree it runs in, so Steps in one group +cannot share a worktree. The same group name after a Step that is not in the +group starts a new group. `optional = true` records that Step's failure and +the Process carries on. Without `optional`, or with `optional = false`, a +failure stops the Process once the other Steps in its group have finished. ```sh thirdshift process @@ -865,26 +916,36 @@ thirdshift process gather-review ``` `thirdshift process` lists each Custom process and its description. `check` -validates the file and prints the plan: the description, a throwaway worktree -at the Base branch, the Command's Harness, Model and Effort, and each Step. -A skill Step shows the skill and where it was found. A command Step shows its -command, and `check` does not run it. An optional Step is marked `(optional)`. -It starts no session. Running the process starts the Steps in order in one -throwaway worktree, detached at the Base branch and cleaned up as a standalone -skill's worktree is. The Base branch is the branch checked out in the launch -directory, at its commit on origin. The launch checkout stays untouched. +validates the file and prints the plan: the description, the Command's Harness, +Model and Effort, and each Step. A Process with no named worktrees prints one +throwaway worktree at the Base branch. A Process that declares worktrees prints +each one, the worktree each Step uses, and a `together:` line for each group +of Steps that run at the same time. A skill Step shows the skill and where it +was found. A command Step shows its command, and `check` does not run it. An +optional Step is marked `(optional)`. `check` starts no session. + +Running the Process starts the Steps in its throwaway worktrees, detached at +the Base branch and cleaned up as a standalone skill's worktree is. Every +throwaway worktree is removed at the end, including after a failure, unless +uncommitted changes, commits absent from every remote-tracking branch, or +uncertain ownership keep it. The Base branch is the branch checked out in the +launch directory, at its commit on origin. The launch checkout stays untouched. Every session Step uses the Command's Harness, Model and Effort: the User config default, or the Harness default when that is unset. A command Step does not. Each finished Step's result is stored beside the Command log, one file per Step. A session's result is its final message. A command's result is its stdout followed by its stderr. The next session Step's prompt names the paths -of those finished results. A Step that fails has no result to pass on. The -last finished Step's result goes to stdout. Progress and failure causes go to -stderr. The first failed Step that is not optional fails the Process, and -later Steps do not run. Exit codes match `thirdshift skill`: `0` when the -Process finishes, including when the only failures were optional Steps, `1` -when a required Step or a check fails, `2` when the arguments are wrong. +of those finished results. Members of a parallel group do not see one +another's results; each member's result is passed on to the Steps after the +group. A Step that fails has no result to pass on. The last finished Step's +result goes to stdout. Progress and failure causes go to stderr. When one +Step in a group fails, the others finish and record their results first. The +group then fails unless every failed Step is optional, and later Steps do not +run. A failed Step outside a group stops the Process immediately, unless that +Step is optional. Exit codes match `thirdshift skill`: `0` when the Process +finishes, including when the only failures were optional Steps, `1` when a +required Step or a check fails, `2` when the arguments are wrong. A command Step's environment sets `THIRDSHIFT_RESULTS_DIR` to the folder of Step result files and `THIRDSHIFT_STEP_RESULT` to the path of that Step's own @@ -895,10 +956,12 @@ has an issue, and is unset when it does not. `check` and running refuse a missing file, a file that is not valid TOML, a missing or wrong `version`, an unknown key, a Step without exactly one of `skill` and `command`, a command that is not a string or is empty, an -`optional` value that is not a boolean, a skill that cannot be found, and a -reserved name (`check`, `list`, `ticket-run`, `spec-run`, `architect-run`, -`pickup-run`, `security-run`). The refusal names the rule and the file line, -and starts no session. +`optional` value that is not a boolean, a skill that cannot be found, an +unknown worktree, a Step that does not name a worktree when the Process +declares them, parallel Steps that share a worktree, a worktree whose kind is +not `throwaway`, and a reserved name (`check`, `list`, `ticket-run`, +`spec-run`, `architect-run`, `pickup-run`, `security-run`). The refusal names +the rule and the file line, and starts no session. The Command is recorded as kind `Process`, with the process name. Its Command log has one line per Step: the Step's name, duration, outcome and cause. A diff --git a/site/docs/index.html b/site/docs/index.html index 916fd48d..d7024bfd 100644 --- a/site/docs/index.html +++ b/site/docs/index.html @@ -754,9 +754,9 @@

Custom processes

-

A Custom process runs Steps in order. A Step is a skill session or a command. -It is experimental. Write ~/.thirdshift/processes/<name>.toml. The name is -the file's stem.

+

A Custom process runs Steps in order, and can run consecutive Steps together. +A Step is a skill session or a command. It is experimental. Write +~/.thirdshift/processes/<name>.toml. The name is the file's stem.

version = 1
 description = "Gather notes, then review them"
 
@@ -773,35 +773,92 @@
 command = "false"
 optional = true
 
+

With no [worktrees] table, every Step shares one throwaway worktree. To run +Steps at the same time, declare a throwaway worktree for each and give +consecutive Steps the same parallel group:

+
version = 1
+description = "Three families, then a judge"
+
+[worktrees.claude]
+kind = "throwaway"
+
+[worktrees.codex]
+kind = "throwaway"
+
+[worktrees.grok]
+kind = "throwaway"
+
+[worktrees.judge]
+kind = "throwaway"
+
+[[steps]]
+name = "claude"
+skill = "thirdshift-code-review"
+worktree = "claude"
+parallel = "families"
+
+[[steps]]
+name = "codex"
+skill = "thirdshift-code-review"
+worktree = "codex"
+parallel = "families"
+
+[[steps]]
+name = "grok"
+skill = "thirdshift-code-review"
+worktree = "grok"
+parallel = "families"
+optional = true
+
+[[steps]]
+name = "judge"
+skill = "thirdshift-to-spec"
+worktree = "judge"
+

version must be 1. Each Step has a name and exactly one kind. skill is looked up the same way as thirdshift skill. command is a command or script, -run with sh -c in the Step's worktree. It starts no session. optional = true -records that Step's failure and the Process carries on. Without optional, or -with optional = false, a failure stops the Process.

+run with sh -c in the Step's worktree. It starts no session. A declared +worktree's kind must be throwaway: it is detached at the Base branch. When +the Process declares worktrees, every Step names one with worktree. +Consecutive Steps with the same parallel name run at the same time. Every +session or command Step edits the worktree it runs in, so Steps in one group +cannot share a worktree. The same group name after a Step that is not in the +group starts a new group. optional = true records that Step's failure and +the Process carries on. Without optional, or with optional = false, a +failure stops the Process once the other Steps in its group have finished.

thirdshift process
 thirdshift process check gather-review
 thirdshift process gather-review
 

thirdshift process lists each Custom process and its description. check -validates the file and prints the plan: the description, a throwaway worktree -at the Base branch, the Command's Harness, Model and Effort, and each Step. -A skill Step shows the skill and where it was found. A command Step shows its -command, and check does not run it. An optional Step is marked (optional). -It starts no session. Running the process starts the Steps in order in one -throwaway worktree, detached at the Base branch and cleaned up as a standalone -skill's worktree is. The Base branch is the branch checked out in the launch -directory, at its commit on origin. The launch checkout stays untouched.

+validates the file and prints the plan: the description, the Command's Harness, +Model and Effort, and each Step. A Process with no named worktrees prints one +throwaway worktree at the Base branch. A Process that declares worktrees prints +each one, the worktree each Step uses, and a together: line for each group +of Steps that run at the same time. A skill Step shows the skill and where it +was found. A command Step shows its command, and check does not run it. An +optional Step is marked (optional). check starts no session.

+

Running the Process starts the Steps in its throwaway worktrees, detached at +the Base branch and cleaned up as a standalone skill's worktree is. Every +throwaway worktree is removed at the end, including after a failure, unless +uncommitted changes, commits absent from every remote-tracking branch, or +uncertain ownership keep it. The Base branch is the branch checked out in the +launch directory, at its commit on origin. The launch checkout stays untouched.

Every session Step uses the Command's Harness, Model and Effort: the User config default, or the Harness default when that is unset. A command Step does not. Each finished Step's result is stored beside the Command log, one file per Step. A session's result is its final message. A command's result is its stdout followed by its stderr. The next session Step's prompt names the paths -of those finished results. A Step that fails has no result to pass on. The -last finished Step's result goes to stdout. Progress and failure causes go to -stderr. The first failed Step that is not optional fails the Process, and -later Steps do not run. Exit codes match thirdshift skill: 0 when the -Process finishes, including when the only failures were optional Steps, 1 -when a required Step or a check fails, 2 when the arguments are wrong.

+of those finished results. Members of a parallel group do not see one +another's results; each member's result is passed on to the Steps after the +group. A Step that fails has no result to pass on. The last finished Step's +result goes to stdout. Progress and failure causes go to stderr. When one +Step in a group fails, the others finish and record their results first. The +group then fails unless every failed Step is optional, and later Steps do not +run. A failed Step outside a group stops the Process immediately, unless that +Step is optional. Exit codes match thirdshift skill: 0 when the Process +finishes, including when the only failures were optional Steps, 1 when a +required Step or a check fails, 2 when the arguments are wrong.

A command Step's environment sets THIRDSHIFT_RESULTS_DIR to the folder of Step result files and THIRDSHIFT_STEP_RESULT to the path of that Step's own result file. thirdshift writes the combined output to that path when the Step @@ -810,10 +867,12 @@

check and running refuse a missing file, a file that is not valid TOML, a missing or wrong version, an unknown key, a Step without exactly one of skill and command, a command that is not a string or is empty, an -optional value that is not a boolean, a skill that cannot be found, and a -reserved name (check, list, ticket-run, spec-run, architect-run, -pickup-run, security-run). The refusal names the rule and the file line, -and starts no session.

+optional value that is not a boolean, a skill that cannot be found, an +unknown worktree, a Step that does not name a worktree when the Process +declares them, parallel Steps that share a worktree, a worktree whose kind is +not throwaway, and a reserved name (check, list, ticket-run, +spec-run, architect-run, pickup-run, security-run). The refusal names +the rule and the file line, and starts no session.

The Command is recorded as kind Process, with the process name. Its Command log has one line per Step: the Step's name, duration, outcome and cause. A command that exits non-zero is failed, and the line names its exit status. diff --git a/site/how-it-works/index.html b/site/how-it-works/index.html index 0b74fd6b..e819c002 100644 --- a/site/how-it-works/index.html +++ b/site/how-it-works/index.html @@ -211,7 +211,7 @@

How thirdshift works

A direct Issue URL starts work you have already chosen. Other commands find work, then dispatch the same Ticket run or Spec run you just followed. Each invocation makes one pass; your operating system's scheduler starts the next one.

To run one skill directly, use thirdshift skill <skill> [issue <issue>]. It starts one headless session on your default Harness, Model and Effort in a temporary worktree at your checkout's committed HEAD, and prints the final message. Worktrees with local work are kept. Standalone skills.

-

thirdshift process is experimental, beside thirdshift skill. It runs the Steps in ~/.thirdshift/processes/<name>.toml in order, a skill session or a command, in one temporary worktree, and prints the last finished Step's result. An optional Step can fail without stopping the process. Custom processes.

+

thirdshift process is experimental, beside thirdshift skill. It runs the Steps in ~/.thirdshift/processes/<name>.toml, a skill session or a command. Consecutive Steps can run together, each in a throwaway worktree the file names, and the last finished Step's result is printed. An optional Step can fail without stopping the process. Custom processes.

Different entry points, the same implementation and Delivery
diff --git a/site/index.html b/site/index.html index 7c9ed205..56a0f450 100644 --- a/site/index.html +++ b/site/index.html @@ -91,7 +91,7 @@

Your issues become pull requests overnight.

  1. /thirdshift-implement
  2. /thirdshift-code-review
  3. /thirdshift-pr

thirdshift takes an Issue URL: one issue starts a Ticket run; an issue with sub-issues starts a Spec run. Its Factory skills, adapted to run with no human in the loop, implement the issue with /thirdshift-implement, review the work with /thirdshift-code-review and write the pull request with /thirdshift-pr.

Issues labelled bug first use /thirdshift-diagnosing-bugs to reproduce the reported failure and write a failing regression test before fixing it. If it cannot reproduce the bug, the Ticket run leaves triage notes and needs-info on the issue, ending successfully without a new pull request. Security fixes and Base fixes keep their own flow.

-

thirdshift skill runs one skill. thirdshift process is experimental: it runs sequential skill or command Steps from ~/.thirdshift/processes. An optional Step can fail without stopping the process. Custom processes.

+

thirdshift skill runs one skill. thirdshift process is experimental: it runs skill or command Steps from ~/.thirdshift/processes, including Steps that run together in throwaway worktrees the file names. An optional Step can fail without stopping the process. Custom processes.

Read every prompt and skill the agents get →

diff --git a/src/processes.rs b/src/processes.rs index 232aae7a..76d2fd17 100644 --- a/src/processes.rs +++ b/src/processes.rs @@ -1,5 +1,7 @@ -//! Custom processes: sequential skill and command Steps from -//! `~/.thirdshift/processes/.toml`. +//! Custom processes: skill and command Steps from `~/.thirdshift/processes/.toml`. +//! +//! Steps run in order. Consecutive Steps that name the same `parallel` group +//! run at the same time, each in a throwaway worktree the Process declares. use std::collections::BTreeSet; use std::fmt::Write; @@ -7,7 +9,8 @@ use std::fs; use std::io::ErrorKind; use std::path::{Path, PathBuf}; use std::process::Command; -use std::time::Instant; +use std::thread; +use std::time::{Duration, Instant}; use anyhow::{Context, Result, anyhow, bail}; use toml_edit::{Document, Item, Key, Table}; @@ -41,13 +44,20 @@ const RESERVED: &[&str] = &[ struct ProcessFile { description: String, + worktrees: Vec, steps: Vec, } +struct WorktreeDraft { + name: String, +} + struct StepDraft { name: String, name_line: usize, optional: bool, + worktree: Option<(String, usize)>, + parallel: Option<(String, usize)>, kind: DraftKind, } @@ -59,6 +69,8 @@ enum DraftKind { struct ResolvedStep { name: String, optional: bool, + worktree: Option, + parallel: Option, kind: StepKind, } @@ -67,8 +79,14 @@ enum StepKind { Command(String), } +struct OwnedWorktree { + name: Option, + checkout: SkillWorktree, +} + struct Prepared { - worktree: SkillWorktree, + worktrees: Vec, + declared: Vec, _written: WrittenGuard, choice: Choice, description: String, @@ -154,8 +172,10 @@ fn run(name: &str) -> Result { fn execute_steps(process: &str, prepared: &Prepared) -> Result { let adapter = prepared.choice.harness.adapter(); - skills::link_into(prepared.worktree.path(), adapter)?; - adapter.link_instruction_fallback(prepared.worktree.path())?; + for worktree in &prepared.worktrees { + skills::link_into(worktree.checkout.path(), adapter)?; + adapter.link_instruction_fallback(worktree.checkout.path())?; + } let results = logs::command_log_path() .context("a Process has no Command log")? .with_extension("steps"); @@ -163,77 +183,167 @@ fn execute_steps(process: &str, prepared: &Prepared) -> Result { .with_context(|| format!("could not create {}", results.display()))?; let mut earlier = Vec::new(); let mut last = String::new(); - for (index, step) in prepared.steps.iter().enumerate() { - let started = Instant::now(); - let path = results.join(format!("{}.txt", step.name)); - match run_step( - process, - prepared, - step, - &earlier, - &results, - &path, - index == 0, - ) { - Ok(message) => { - let duration = progress::duration(started.elapsed()); - progress::step(format_args!("step {}: {duration} succeeded", step.name)); - fs::write(&path, &message) - .with_context(|| format!("could not write {}", path.display()))?; - earlier.push((step.name.clone(), path)); - last = message; - } - Err(error) => { - let duration = progress::duration(started.elapsed()); - progress::step(format_args!( - "step {}: {duration} {}: {}", - step.name, - failure_outcome(), - one_line(&error) - )); - if step.optional && !interrupt::requested() { - continue; - } - return Err(error); + for range in group_ranges(&prepared.steps) { + let group = &prepared.steps[range.clone()]; + let outcomes = if group.len() == 1 { + let started = Instant::now(); + let outcome = run_step( + process, + prepared, + &group[0], + &earlier, + &results, + range.start == 0, + ); + vec![(started.elapsed(), outcome)] + } else { + run_parallel(process, prepared, group, &earlier, &results, range.start) + }; + let mut group_error = None; + for (step, (elapsed, outcome)) in group.iter().zip(outcomes) { + match finish_step(step, elapsed, outcome, &results, &mut earlier, &mut last) { + Ok(()) => {} + Err(error) if group_error.is_none() => group_error = Some(error), + Err(_) => {} } } + if let Some(error) = group_error { + return Err(error); + } } Ok(last) } +/// Consecutive items that share a group name. An item with no group, and a +/// repeat of a name after an item outside it, each start their own range. +fn consecutive_groups( + items: &[T], + name_of: impl Fn(&T) -> Option<&str>, +) -> Vec> { + let mut ranges = Vec::new(); + let mut index = 0; + while index < items.len() { + let start = index; + let group = name_of(&items[index]).map(str::to_string); + index += 1; + if let Some(group) = group { + while index < items.len() && name_of(&items[index]) == Some(group.as_str()) { + index += 1; + } + } + ranges.push(start..index); + } + ranges +} + +fn group_ranges(steps: &[ResolvedStep]) -> Vec> { + consecutive_groups(steps, |step| step.parallel.as_deref()) +} + +fn run_parallel( + process: &str, + prepared: &Prepared, + group: &[ResolvedStep], + earlier: &[(String, PathBuf)], + results: &Path, + group_start: usize, +) -> Vec<(Duration, Result)> { + thread::scope(|scope| { + let mut handles = Vec::with_capacity(group.len()); + for (offset, step) in group.iter().enumerate() { + let cwd = worktree_path(&prepared.worktrees, step.worktree.as_deref()).to_path_buf(); + let choice = prepared.choice.clone(); + let earlier = earlier.to_vec(); + let process = process.to_string(); + let results = results.to_path_buf(); + let first = group_start + offset == 0; + handles.push(scope.spawn(move || { + let started = Instant::now(); + let outcome = run_step_in(&process, &choice, &cwd, step, &earlier, &results, first); + (started.elapsed(), outcome) + })); + } + handles + .into_iter() + .map(|handle| handle.join().expect("a step thread panicked")) + .collect() + }) +} + +fn finish_step( + step: &ResolvedStep, + elapsed: Duration, + outcome: Result, + results: &Path, + earlier: &mut Vec<(String, PathBuf)>, + last: &mut String, +) -> Result<()> { + let duration = progress::duration(elapsed); + match outcome { + Ok(message) => { + progress::step(format_args!("step {}: {duration} succeeded", step.name)); + let path = results.join(format!("{}.txt", step.name)); + fs::write(&path, &message) + .with_context(|| format!("could not write {}", path.display()))?; + earlier.push((step.name.clone(), path)); + *last = message; + Ok(()) + } + Err(error) => { + progress::step(format_args!( + "step {}: {duration} {}: {}", + step.name, + failure_outcome(), + one_line(&error) + )); + if step.optional && !interrupt::requested() { + Ok(()) + } else { + Err(error) + } + } + } +} + fn run_step( process: &str, prepared: &Prepared, step: &ResolvedStep, earlier: &[(String, PathBuf)], results: &Path, - result_file: &Path, + first: bool, +) -> Result { + run_step_in( + process, + &prepared.choice, + worktree_path(&prepared.worktrees, step.worktree.as_deref()), + step, + earlier, + results, + first, + ) +} + +fn run_step_in( + process: &str, + choice: &Choice, + cwd: &Path, + step: &ResolvedStep, + earlier: &[(String, PathBuf)], + results: &Path, first: bool, ) -> Result { crate::resources::wait()?; match &step.kind { StepKind::Skill { name, path } => { - progress::step(format_args!( - "running step {} with {}", - step.name, prepared.choice - )); + progress::step(format_args!("running step {} with {choice}", step.name)); let prompt = prompt::process_step(process, &step.name, name, path, earlier, first); - skill::run_session( - prepared.worktree.path(), - &prepared.choice, - &format!("Step {}", step.name), - &prompt, - ) + skill::run_session(cwd, choice, &format!("Step {}", step.name), &prompt) } StepKind::Command(command) => { progress::step(format_args!("running step {}", step.name)); - run_command( - prepared.worktree.path(), - command, - results, - result_file, - None, - ) + let result_file = results.join(format!("{}.txt", step.name)); + run_command(cwd, command, results, &result_file, None) } } } @@ -288,6 +398,20 @@ fn exit_status(status: std::process::ExitStatus) -> String { } } +fn worktree_path<'a>(worktrees: &'a [OwnedWorktree], name: Option<&str>) -> &'a Path { + worktrees + .iter() + .find(|worktree| worktree.name.as_deref() == name) + .unwrap_or_else(|| { + panic!( + "missing worktree {}", + name.unwrap_or("the throwaway worktree") + ) + }) + .checkout + .path() +} + fn classify(result: &Result) -> (String, Option) { match result { Ok(_) => ("succeeded".to_string(), None), @@ -313,45 +437,84 @@ fn one_line(error: &anyhow::Error) -> String { fn plan(name: &str, prepared: &Prepared) -> String { let mut text = format!( - "{name}: {description}\nthrowaway worktree at the Base branch\nharness: {choice} ({chosen})\n", - description = prepared.description, + "{name}: {description}\n", + description = prepared.description + ); + if prepared.declared.is_empty() { + text.push_str("throwaway worktree at the Base branch\n"); + } + let _ = writeln!( + text, + "harness: {choice} ({chosen})", choice = prepared.choice, chosen = prepared.choice.chosen_by, ); - for step in &prepared.steps { - let mut line = match &step.kind { - StepKind::Skill { name, path } => { - format!("{}: {} {}", step.name, name, path.display()) - } - StepKind::Command(command) => { - let shown = command.replace(['\n', '\r'], " "); - format!("{}: command {shown}", step.name) - } - }; - if step.optional { - line.push_str(" (optional)"); + for worktree in &prepared.declared { + let _ = writeln!(text, "worktree {worktree}: throwaway at the Base branch"); + } + let named = !prepared.declared.is_empty(); + for range in group_ranges(&prepared.steps) { + let names: Vec<_> = prepared.steps[range.clone()] + .iter() + .map(|step| step.name.as_str()) + .collect(); + for step in &prepared.steps[range.clone()] { + let _ = writeln!(text, "{}", step_plan(step, named)); + } + if names.len() > 1 { + let _ = writeln!(text, "together: {}", names.join(", ")); } - let _ = writeln!(text, "{line}"); } text.trim_end().to_string() } +fn step_plan(step: &ResolvedStep, named: bool) -> String { + let mut line = match &step.kind { + StepKind::Skill { name, path } => { + format!("{}: {} {}", step.name, name, path.display()) + } + StepKind::Command(command) => { + let shown = command.replace(['\n', '\r'], " "); + format!("{}: command {shown}", step.name) + } + }; + if named && let Some(worktree) = &step.worktree { + let _ = write!(line, " (worktree {worktree})"); + } + if step.optional { + line.push_str(" (optional)"); + } + line +} + fn prepare(name: &str, config: &UserConfig) -> Result { let path = process_path(name)?; refuse_reserved(&path, name)?; let text = read_process(&path)?; let file = parse(&path, &text)?; + refuse_worktrees(&path, &file)?; let base = launch::checked_out_base()?; - let worktree = SkillWorktree::at(&Git::new(std::env::current_dir()?), base.origin_commit())?; + let launch = Git::new(std::env::current_dir()?); + let commit = base.origin_commit(); let written = WrittenGuard; + let worktrees = create_worktrees(&launch, commit, &file)?; let mut steps = Vec::new(); for step in &file.steps { - steps.push(resolve(&path, step, worktree.path())?); + let checkout = worktree_path( + &worktrees, + step.worktree.as_ref().map(|(name, _)| name.as_str()), + ); + steps.push(resolve(&path, step, checkout)?); } let mut choice = Choice::of(&Asked::default(), &config.harness); choice.check_without_probe()?; Ok(Prepared { - worktree, + worktrees, + declared: file + .worktrees + .into_iter() + .map(|worktree| worktree.name) + .collect(), _written: written, choice, description: file.description, @@ -359,6 +522,99 @@ fn prepare(name: &str, config: &UserConfig) -> Result { }) } +fn create_worktrees(launch: &Git, commit: &str, file: &ProcessFile) -> Result> { + if file.worktrees.is_empty() { + return Ok(vec![OwnedWorktree { + name: None, + checkout: SkillWorktree::at(launch, commit)?, + }]); + } + let mut worktrees = Vec::new(); + let mut seen = BTreeSet::new(); + for step in &file.steps { + let Some((name, _)) = &step.worktree else { + continue; + }; + if !seen.insert(name.clone()) { + continue; + } + worktrees.push(OwnedWorktree { + name: Some(name.clone()), + checkout: SkillWorktree::at(launch, commit)?, + }); + } + Ok(worktrees) +} + +fn refuse_worktrees(path: &Path, file: &ProcessFile) -> Result<()> { + let declared: BTreeSet<&str> = file + .worktrees + .iter() + .map(|worktree| worktree.name.as_str()) + .collect(); + for step in &file.steps { + match &step.worktree { + Some((name, line)) if !declared.contains(name.as_str()) => { + return Err(rule( + path, + *line, + "unknown worktree", + format!("step {} names worktree {name}", step.name), + )); + } + None if !declared.is_empty() => { + return Err(rule( + path, + step.name_line, + "worktree", + format!("step {} does not name a worktree", step.name), + )); + } + _ => {} + } + } + for range in consecutive_groups(&file.steps, |step| { + step.parallel.as_ref().map(|(name, _)| name.as_str()) + }) { + if range.len() < 2 { + continue; + } + let mut seen: Vec<(&str, &str)> = Vec::new(); + for step in &file.steps[range] { + let worktree = step + .worktree + .as_ref() + .map(|(name, _)| name.as_str()) + .unwrap_or(""); + if let Some(other) = seen + .iter() + .find(|(name, _)| *name == worktree) + .map(|(_, step)| *step) + { + let line = step + .worktree + .as_ref() + .map(|(_, line)| *line) + .or_else(|| step.parallel.as_ref().map(|(_, line)| *line)) + .unwrap_or(step.name_line); + let shared = if worktree.is_empty() { + "the throwaway worktree".to_string() + } else { + format!("worktree {worktree}") + }; + return Err(rule( + path, + line, + "shared worktree", + format!("steps {other} and {} share {shared}", step.name), + )); + } + seen.push((worktree, step.name.as_str())); + } + } + Ok(()) +} + fn resolve(path: &Path, step: &StepDraft, worktree: &Path) -> Result { let kind = match &step.kind { DraftKind::Skill { skill, line } => match skills::find(worktree, skill) { @@ -375,6 +631,8 @@ fn resolve(path: &Path, step: &StepDraft, worktree: &Path) -> Result Result { let table = doc.as_table(); let mut saw_version = false; let mut description = None; + let mut worktrees = Vec::new(); let mut steps = None; for (key, item) in table.iter() { let line = key_line(text, table.key(key)); @@ -437,6 +696,9 @@ fn parse(path: &Path, text: &str) -> Result { "description" => { description = Some(description_of(path, line, item)?); } + "worktrees" => { + worktrees = parse_worktrees(path, text, line, item)?; + } "steps" => { let parsed = parse_steps(path, text, line, item)?; if parsed.is_empty() { @@ -456,7 +718,88 @@ fn parse(path: &Path, text: &str) -> Result { let Some(steps) = steps else { return Err(rule(path, 1, "steps", "at least one step is required")); }; - Ok(ProcessFile { description, steps }) + Ok(ProcessFile { + description, + worktrees, + steps, + }) +} + +fn parse_worktrees( + path: &Path, + text: &str, + line: usize, + item: &Item, +) -> Result> { + let Some(table) = item.as_table() else { + return Err(rule( + path, + line, + "worktrees", + "worktrees must be a table of named worktrees", + )); + }; + if table.is_empty() { + return Err(rule( + path, + line, + "worktrees", + "at least one worktree is required", + )); + } + let mut worktrees = Vec::new(); + for (key, item) in table.iter() { + let name_line = key_line(text, table.key(key)); + if !single_component(key) { + return Err(rule( + path, + name_line, + "worktree name", + "a worktree name must be a single path component", + )); + } + let Some(body) = item.as_table() else { + return Err(rule( + path, + name_line, + "worktree", + "a worktree must be a table", + )); + }; + let mut saw_kind = false; + for (field, value) in body.iter() { + let field_line = key_line(text, body.key(field)); + match field { + "kind" => { + let Some(kind) = value.as_str() else { + return Err(rule( + path, + field_line, + "worktree kind", + "kind must be a string", + )); + }; + if kind != "throwaway" { + return Err(rule( + path, + field_line, + "worktree kind", + "kind must be throwaway", + )); + } + saw_kind = true; + } + other => return Err(rule(path, field_line, "unknown key", other)), + } + } + if !saw_kind { + return Err(rule(path, name_line, "worktree kind", "kind is required")); + } + worktrees.push(WorktreeDraft { + name: key.to_string(), + }); + } + Ok(worktrees) } fn description_of(path: &Path, line: usize, item: &Item) -> Result { @@ -521,6 +864,8 @@ fn parse_step(path: &Path, text: &str, table_line: usize, table: &Table) -> Resu let mut name = None; let mut skill = None; let mut command = None; + let mut worktree = None; + let mut parallel = None; let mut optional = false; for (key, item) in table.iter() { let line = key_line(text, table.key(key)); @@ -528,6 +873,8 @@ fn parse_step(path: &Path, text: &str, table_line: usize, table: &Table) -> Resu "name" => name = Some(step_name(path, line, item)?), "skill" => skill = Some(step_skill(path, line, item)?), "command" => command = Some(step_command(path, line, item)?), + "worktree" => worktree = Some(step_worktree(path, line, item)?), + "parallel" => parallel = Some(step_parallel(path, line, item)?), "optional" => optional = step_optional(path, line, item)?, other => return Err(rule(path, line, "unknown key", other)), } @@ -559,10 +906,42 @@ fn parse_step(path: &Path, text: &str, table_line: usize, table: &Table) -> Resu name, name_line, optional, + worktree, + parallel, kind, }) } +fn step_worktree(path: &Path, line: usize, item: &Item) -> Result<(String, usize)> { + let Some(value) = item.as_str() else { + return Err(rule(path, line, "worktree", "worktree must be a string")); + }; + if !single_component(value) { + return Err(rule( + path, + line, + "worktree", + "worktree must be a single path component", + )); + } + Ok((value.to_string(), line)) +} + +fn step_parallel(path: &Path, line: usize, item: &Item) -> Result<(String, usize)> { + let Some(value) = item.as_str() else { + return Err(rule(path, line, "parallel", "parallel must be a string")); + }; + if value.is_empty() || value.chars().any(|c| c == '\n' || c == '\r') { + return Err(rule( + path, + line, + "parallel", + "parallel must be a single-line group name", + )); + } + Ok((value.to_string(), line)) +} + fn step_optional(path: &Path, line: usize, item: &Item) -> Result { item.as_bool() .ok_or_else(|| rule(path, line, "optional", "optional must be a boolean")) diff --git a/tests/process.rs b/tests/process.rs index e5fa8e89..c0423ea7 100644 --- a/tests/process.rs +++ b/tests/process.rs @@ -895,6 +895,678 @@ fn stop_interrupts_a_running_process() { assert_eq!(record["outcome"], "interrupted"); } +const FAMILIES: &str = "\ +version = 1 +description = \"Three families, then a judge\" + +[worktrees.claude] +kind = \"throwaway\" + +[worktrees.codex] +kind = \"throwaway\" + +[worktrees.grok] +kind = \"throwaway\" + +[worktrees.judge] +kind = \"throwaway\" + +[[steps]] +name = \"claude\" +skill = \"code-review\" +worktree = \"claude\" +parallel = \"families\" + +[[steps]] +name = \"codex\" +skill = \"code-review\" +worktree = \"codex\" +parallel = \"families\" + +[[steps]] +name = \"grok\" +skill = \"code-review\" +worktree = \"grok\" +parallel = \"families\" + +[[steps]] +name = \"judge\" +skill = \"to-spec\" +worktree = \"judge\" +"; + +#[test] +fn check_prints_each_steps_worktree_and_which_steps_run_together() { + let scenario = Scenario::new(); + write_process(&scenario, "families", FAMILIES); + + let result = scenario.run(&["process", "check", "families"]); + + assert_eq!(result.code, Some(0), "{}", result.stderr); + assert!(scenario.claude_calls().is_empty(), "a session started"); + assert!( + result + .stdout + .contains("families: Three families, then a judge") + ); + for name in ["claude", "codex", "grok", "judge"] { + assert!( + result + .stdout + .contains(&format!("worktree {name}: throwaway at the Base branch")), + "{}", + result.stdout + ); + assert!( + result.stdout.contains(&format!("(worktree {name})")), + "{}", + result.stdout + ); + } + assert!(result.stdout.contains("together: claude, codex, grok\n")); + assert_eq!( + result.stdout.matches("together:").count(), + 1, + "{}", + result.stdout + ); + assert!(result.stdout.contains("SKILL.md"), "{}", result.stdout); + assert!( + !result + .stdout + .contains("together: claude, codex, grok, judge") + ); + assert_eq!(worktree_count(&scenario), 1); + assert!(!process_dir(&scenario).exists()); +} + +#[test] +fn parallel_steps_that_share_a_worktree_or_name_an_unknown_one_are_refused() { + let cases = [ + ( + "\ +version = 1 +description = \"Shared\" + +[worktrees.one] +kind = \"throwaway\" + +[[steps]] +name = \"a\" +skill = \"code-review\" +worktree = \"one\" +parallel = \"g\" + +[[steps]] +name = \"b\" +skill = \"code-review\" +worktree = \"one\" +parallel = \"g\" +", + ":16: shared worktree:", + ), + ( + "\ +version = 1 +description = \"Implicit\" + +[[steps]] +name = \"a\" +skill = \"code-review\" +parallel = \"g\" + +[[steps]] +name = \"b\" +skill = \"code-review\" +parallel = \"g\" +", + ":12: shared worktree:", + ), + ( + "\ +version = 1 +description = \"Commands\" + +[[steps]] +name = \"a\" +command = \"true\" +parallel = \"g\" + +[[steps]] +name = \"b\" +command = \"true\" +parallel = \"g\" +", + ":12: shared worktree:", + ), + ( + "\ +version = 1 +description = \"Missing\" + +[[steps]] +name = \"a\" +skill = \"code-review\" +worktree = \"missing\" +", + ":7: unknown worktree:", + ), + ( + "\ +version = 1 +description = \"Nameless\" + +[worktrees.one] +kind = \"throwaway\" + +[[steps]] +name = \"a\" +skill = \"code-review\" +", + ":8: worktree:", + ), + ( + "\ +version = 1 +description = \"Branch\" + +[worktrees.fix] +kind = \"issue-branch\" +", + ":5: worktree kind:", + ), + ]; + for check in [true, false] { + for (body, expect) in cases { + let scenario = Scenario::new(); + write_process(&scenario, "families", body); + let result = if check { + scenario.run(&["process", "check", "families"]) + } else { + scenario.run(&["process", "families"]) + }; + assert_no_session(&scenario, &result); + assert!( + result.stderr.contains(expect), + "check={check} expected {expect} in {}", + result.stderr + ); + assert_eq!(worktree_count(&scenario), 1, "{}", result.stderr); + } + } +} + +#[test] +fn three_parallel_steps_overlap_and_the_next_step_sees_every_result() { + let scenario = Scenario::new(); + write_process(&scenario, "families", FAMILIES); + scenario.agent_does(&family_sessions( + "printf '%s\\n' \"$name\" > \"$FAKE_CLAUDE_FINAL_MESSAGE\"", + "\ +for step in claude codex grok; do + path=$(printf '%s\\n' \"$FAKE_CLAUDE_PROMPT\" | sed -n \"s/^Earlier Step result ${step}: //p\") + test -n \"$path\" + test \"$(cat \"$path\")\" = \"$step\" +done +printf 'judged' > \"$FAKE_CLAUDE_FINAL_MESSAGE\" +", + )); + + let result = scenario.run(&["process", "families"]); + + assert_eq!(result.code, Some(0), "{}", result.stderr); + assert_eq!(result.stdout, "judged\n"); + let calls = scenario.claude_calls(); + assert_eq!(calls.len(), 4, "{}", result.stderr); + let mut directories = Vec::new(); + for step in ["claude", "codex", "grok", "judge"] { + let prompt = prompt_of(&calls, step); + let cwd = cwd_of(&calls, step); + assert_ne!(cwd, scenario.launch_dir()); + assert!(!cwd.exists(), "{step} worktree kept: {}", cwd.display()); + directories.push(cwd.to_path_buf()); + if step == "claude" { + assert!( + prompt.contains("This is the first Step. No earlier Step has a result."), + "{step}: {prompt}" + ); + } else if step == "judge" { + assert!(!prompt.contains("This is the first Step"), "{prompt}"); + assert!( + !prompt.contains("No earlier Step has a result."), + "{prompt}" + ); + } else { + assert!( + prompt.contains("No earlier Step has a result."), + "{step}: {prompt}" + ); + assert!( + !prompt.contains("This is the first Step"), + "{step}: {prompt}" + ); + } + } + directories.sort(); + directories.dedup(); + assert_eq!(directories.len(), 4, "parallel steps shared a worktree"); + let (log_path, log) = command_log(&scenario); + for step in ["claude", "codex", "grok", "judge"] { + assert!(step_line(&log, step).contains("succeeded"), "{log}"); + } + let steps = log_path.with_extension("steps"); + for step in ["claude", "codex", "grok"] { + assert_eq!( + fs::read_to_string(steps.join(format!("{step}.txt"))).unwrap(), + format!("{step}\n") + ); + } + assert_eq!(worktree_count(&scenario), 1); + assert_eq!(process_record(&scenario)["outcome"], "succeeded"); +} + +#[test] +fn a_failed_parallel_step_lets_the_others_finish_before_the_process_fails() { + let scenario = Scenario::new(); + write_process(&scenario, "families", FAMILIES); + scenario.agent_does(&family_sessions( + "\ +if [ \"$name\" = \"codex\" ]; then + exit 1 +fi +sleep 0.3 +printf '%s\\n' \"$name\" > \"$FAKE_CLAUDE_FINAL_MESSAGE\" +", + "printf 'judge ran\\n' > \"$FAKE_CLAUDE_FINAL_MESSAGE\"", + )); + + let result = scenario.run(&["process", "families"]); + + assert_eq!(result.code, Some(1), "{}", result.stderr); + assert_eq!(result.stdout, ""); + let calls = scenario.claude_calls(); + assert_eq!(calls.len(), 3, "{}", result.stderr); + assert!(prompt_of_optional(&calls, "judge").is_none(), "{calls:?}"); + for step in ["claude", "grok"] { + assert!(!cwd_of(&calls, step).exists(), "{step} kept"); + } + assert!(!cwd_of(&calls, "codex").exists(), "failed worktree kept"); + let (log_path, log) = command_log(&scenario); + assert!(step_line(&log, "claude").contains("succeeded"), "{log}"); + assert!(step_line(&log, "grok").contains("succeeded"), "{log}"); + assert!(step_line(&log, "codex").contains("failed"), "{log}"); + assert!(!log.contains("step judge:"), "{log}"); + let steps = log_path.with_extension("steps"); + assert_eq!( + fs::read_to_string(steps.join("claude.txt")).unwrap(), + "claude\n" + ); + assert_eq!( + fs::read_to_string(steps.join("grok.txt")).unwrap(), + "grok\n" + ); + assert!(!steps.join("codex.txt").exists()); + assert!(!steps.join("judge.txt").exists()); + assert_eq!(worktree_count(&scenario), 1, "{}", result.stderr); + assert_eq!(process_record(&scenario)["outcome"], "failed"); +} + +#[test] +fn a_failed_parallel_step_keeps_only_a_worktree_retention_would_keep() { + let scenario = Scenario::new(); + write_process(&scenario, "families", FAMILIES); + scenario.agent_does(&family_sessions( + "\ +if [ \"$name\" = \"codex\" ]; then + printf 'kept\\n' > leftover.txt + exit 1 +fi +printf '%s\\n' \"$name\" > \"$FAKE_CLAUDE_FINAL_MESSAGE\" +", + "printf 'judge ran\\n' > \"$FAKE_CLAUDE_FINAL_MESSAGE\"", + )); + + let result = scenario.run(&["process", "families"]); + + assert_eq!(result.code, Some(1), "{}", result.stderr); + let calls = scenario.claude_calls(); + assert_eq!(calls.len(), 3, "{}", result.stderr); + let kept = cwd_of(&calls, "codex"); + assert!( + kept.exists(), + "dirty worktree was removed: {}", + result.stderr + ); + assert_eq!( + fs::read_to_string(kept.join("leftover.txt")).unwrap(), + "kept\n" + ); + assert!( + result.stderr.contains("retaining checkout"), + "{}", + result.stderr + ); + for step in ["claude", "grok"] { + assert!(!cwd_of(&calls, step).exists(), "{step} kept"); + } + assert_eq!(worktree_count(&scenario), 2, "{}", result.stderr); + assert_eq!( + scenario.launch_git(&["status", "--porcelain", "--untracked-files=all"]), + "" + ); +} + +#[test] +fn an_optional_parallel_failure_does_not_fail_the_group() { + let scenario = Scenario::new(); + let mut body = FAMILIES.to_string(); + body = body.replacen( + "\ +name = \"codex\" +skill = \"code-review\" +worktree = \"codex\" +parallel = \"families\" +", + "\ +name = \"codex\" +skill = \"code-review\" +worktree = \"codex\" +parallel = \"families\" +optional = true +", + 1, + ); + write_process(&scenario, "families", &body); + let plan = scenario.run(&["process", "check", "families"]); + assert_eq!(plan.code, Some(0), "{}", plan.stderr); + assert!( + plan.stdout.contains("(worktree codex) (optional)"), + "{}", + plan.stdout + ); + scenario.agent_does(&family_sessions( + "\ +if [ \"$name\" = \"codex\" ]; then + exit 1 +fi +printf '%s\\n' \"$name\" > \"$FAKE_CLAUDE_FINAL_MESSAGE\" +", + "\ +if printf '%s\\n' \"$FAKE_CLAUDE_PROMPT\" | grep -q 'Earlier Step result codex:'; then + exit 1 +fi +for step in claude grok; do + path=$(printf '%s\\n' \"$FAKE_CLAUDE_PROMPT\" | sed -n \"s/^Earlier Step result ${step}: //p\") + test \"$(cat \"$path\")\" = \"$step\" +done +printf 'judged' > \"$FAKE_CLAUDE_FINAL_MESSAGE\" +", + )); + + let result = scenario.run(&["process", "families"]); + + assert_eq!(result.code, Some(0), "{}", result.stderr); + assert_eq!(result.stdout, "judged\n"); + let calls = scenario.claude_calls(); + assert_eq!(calls.len(), 4, "{}", result.stderr); + let (_, log) = command_log(&scenario); + assert!(step_line(&log, "codex").contains("failed"), "{log}"); + for step in ["claude", "grok", "judge"] { + assert!(step_line(&log, step).contains("succeeded"), "{log}"); + assert!(!cwd_of(&calls, step).exists(), "{step} kept"); + } + assert!(!cwd_of(&calls, "codex").exists()); + assert_eq!(worktree_count(&scenario), 1); + assert_eq!(process_record(&scenario)["outcome"], "succeeded"); +} + +#[test] +fn the_same_parallel_name_after_another_step_is_a_new_group() { + let scenario = Scenario::new(); + write_process( + &scenario, + "groups", + "\ +version = 1 +description = \"Two groups of the same name\" + +[worktrees.wa] +kind = \"throwaway\" + +[worktrees.wb] +kind = \"throwaway\" + +[worktrees.wm] +kind = \"throwaway\" + +[worktrees.wc] +kind = \"throwaway\" + +[worktrees.wd] +kind = \"throwaway\" + +[worktrees.wj] +kind = \"throwaway\" + +[[steps]] +name = \"a\" +skill = \"code-review\" +worktree = \"wa\" +parallel = \"g\" + +[[steps]] +name = \"b\" +skill = \"code-review\" +worktree = \"wb\" +parallel = \"g\" + +[[steps]] +name = \"mid\" +skill = \"to-spec\" +worktree = \"wm\" + +[[steps]] +name = \"c\" +skill = \"code-review\" +worktree = \"wc\" +parallel = \"g\" + +[[steps]] +name = \"d\" +skill = \"code-review\" +worktree = \"wd\" +parallel = \"g\" + +[[steps]] +name = \"judge\" +skill = \"pr\" +worktree = \"wj\" +", + ); + scenario.agent_does( + r#"name=$(printf '%s\n' "$FAKE_CLAUDE_PROMPT" | sed -n 's/^This is Step \([^ ]*\) of the Custom process .*/\1/p') +root=$(dirname "$FAKE_CLAUDE_RECORD") +overlap() { + dir=$1 + shift + mkdir -p "$dir" + touch "$dir/$name" + ready=0 + for _ in $(seq 1 100); do + ready=1 + for member in "$@"; do + if [ ! -f "$dir/$member" ]; then + ready=0 + fi + done + if [ "$ready" = 1 ]; then + break + fi + sleep 0.05 + done + test "$ready" = 1 +} +has() { + printf '%s\n' "$FAKE_CLAUDE_PROMPT" | grep -q "Earlier Step result $1:" +} +case "$name" in + a|b) + if has mid || has c || has d; then exit 1; fi + overlap "$root/first" a b + printf '%s\n' "$name" > "$FAKE_CLAUDE_FINAL_MESSAGE" + ;; + mid) + for step in a b; do + path=$(printf '%s\n' "$FAKE_CLAUDE_PROMPT" | sed -n "s/^Earlier Step result ${step}: //p") + test "$(cat "$path")" = "$step" + done + if has c || has d; then exit 1; fi + printf 'mid\n' > "$FAKE_CLAUDE_FINAL_MESSAGE" + ;; + c|d) + for step in a b mid; do + path=$(printf '%s\n' "$FAKE_CLAUDE_PROMPT" | sed -n "s/^Earlier Step result ${step}: //p") + test -n "$path" + done + other=d + if [ "$name" = "d" ]; then other=c; fi + if has "$other"; then exit 1; fi + overlap "$root/second" c d + printf '%s\n' "$name" > "$FAKE_CLAUDE_FINAL_MESSAGE" + ;; + judge) + for step in a b mid c d; do + path=$(printf '%s\n' "$FAKE_CLAUDE_PROMPT" | sed -n "s/^Earlier Step result ${step}: //p") + test "$(cat "$path")" = "$step" + done + printf 'judged' > "$FAKE_CLAUDE_FINAL_MESSAGE" + ;; +esac +"#, + ); + + let result = scenario.run(&["process", "groups"]); + + assert_eq!(result.code, Some(0), "{}", result.stderr); + assert_eq!(result.stdout, "judged\n"); + assert_eq!(scenario.claude_calls().len(), 6, "{}", result.stderr); + let plan = scenario.run(&["process", "check", "groups"]); + assert!(plan.stdout.contains("together: a, b\n"), "{}", plan.stdout); + assert!(plan.stdout.contains("together: c, d\n"), "{}", plan.stdout); + assert_eq!( + plan.stdout.matches("together:").count(), + 2, + "{}", + plan.stdout + ); + assert_eq!(worktree_count(&scenario), 1); +} + +#[test] +fn an_optional_step_failure_is_recorded_and_later_steps_run() { + let scenario = Scenario::new(); + write_process( + &scenario, + "notes", + "\ +version = 1 +description = \"Optional gather\" + +[[steps]] +name = \"gather\" +skill = \"to-spec\" +optional = true + +[[steps]] +name = \"judge\" +skill = \"code-review\" +", + ); + scenario.agent_does_in_session(1, "exit 1"); + scenario.agent_does_in_session( + 2, + "\ +if printf '%s\\n' \"$FAKE_CLAUDE_PROMPT\" | grep -q 'Earlier Step result gather:'; then + exit 1 +fi +printf 'judged' > \"$FAKE_CLAUDE_FINAL_MESSAGE\" +", + ); + + let result = scenario.run(&["process", "notes"]); + + assert_eq!(result.code, Some(0), "{}", result.stderr); + assert_eq!(result.stdout, "judged\n"); + assert_eq!(scenario.claude_calls().len(), 2, "{}", result.stderr); + let (_, log) = command_log(&scenario); + assert!(step_line(&log, "gather").contains("failed"), "{log}"); + assert!(step_line(&log, "judge").contains("succeeded"), "{log}"); + assert_eq!(process_record(&scenario)["outcome"], "succeeded"); + assert_eq!(worktree_count(&scenario), 1); +} + +fn family_sessions(members: &str, judge: &str) -> String { + format!( + r#"name=$(printf '%s\n' "$FAKE_CLAUDE_PROMPT" | sed -n 's/^This is Step \([^ ]*\) of the Custom process .*/\1/p') +root=$(dirname "$FAKE_CLAUDE_RECORD") +case "$name" in + claude|codex|grok) + mkdir -p "$root/overlap" + touch "$root/overlap/$name" + ready=0 + for _ in $(seq 1 100); do + if [ -f "$root/overlap/claude" ] && [ -f "$root/overlap/codex" ] && [ -f "$root/overlap/grok" ]; then + ready=1 + break + fi + sleep 0.05 + done + test "$ready" = 1 + {members} + ;; + judge) + {judge} + ;; +esac +"# + ) +} + +fn prompt_of<'a>(calls: &'a [serde_json::Value], step: &str) -> &'a str { + prompt_of_optional(calls, step).unwrap_or_else(|| panic!("no session for {step}")) +} + +fn prompt_of_optional<'a>(calls: &'a [serde_json::Value], step: &str) -> Option<&'a str> { + let needle = format!("This is Step {step} of the Custom process"); + calls.iter().find_map(|call| { + call["prompt"] + .as_str() + .filter(|prompt| prompt.contains(&needle)) + }) +} + +fn cwd_of<'a>(calls: &'a [serde_json::Value], step: &str) -> &'a Path { + let needle = format!("This is Step {step} of the Custom process"); + let cwd = calls + .iter() + .find(|call| { + call["prompt"] + .as_str() + .is_some_and(|prompt| prompt.contains(&needle)) + }) + .unwrap_or_else(|| panic!("no session for {step}"))["cwd"] + .as_str() + .unwrap(); + Path::new(cwd) +} + +fn worktree_count(scenario: &Scenario) -> usize { + scenario + .launch_git(&["worktree", "list", "--porcelain"]) + .matches("worktree ") + .count() +} + fn step_line<'a>(log: &'a str, name: &str) -> &'a str { log.lines() .find(|line| line.contains(&format!("step {name}:")))