From 92f96b4bacf1c9f29ffa2d47376f608b6f589358 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Mon, 26 Oct 2026 16:35:26 -0400 Subject: [PATCH] Run parallel Custom process Steps in named throwaway worktrees (#1383) A process can declare throwaway worktrees and group consecutive Steps that share a parallel name. A group runs together, each Step in its own worktree, and a failure waits for the other members before the process stops unless that Step is optional. --- README.md | 2 +- docs/guide.md | 105 ++++-- site/docs/index.html | 101 ++++-- site/how-it-works/index.html | 2 +- site/index.html | 2 +- src/processes.rs | 534 +++++++++++++++++++++++++---- tests/process.rs | 642 +++++++++++++++++++++++++++++++++++ 7 files changed, 1284 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index a5def7be..c839d5d0 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 Steps from `~/.thirdshift/processes/.toml`. | +| A Custom process | `thirdshift process ` | An experimental [Custom process](docs/guide.md#custom-processes) runs skill Steps from `~/.thirdshift/processes/.toml`, in named throwaway worktrees when the file declares them. | 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 43b9f0a6..291b1a86 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -830,8 +830,9 @@ Command record, Console entry or Session log is created for a standalone skill. ### Custom processes -A Custom process runs skill Steps in order. It is experimental. Write -`~/.thirdshift/processes/.toml`. The name is the file's stem. +A Custom process runs skill Steps in order, and can run consecutive Steps +together. It is experimental. Write `~/.thirdshift/processes/.toml`. +The name is the file's stem. ```toml version = 1 @@ -846,8 +847,59 @@ name = "review" skill = "thirdshift-code-review" ``` +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 one kind. This version's kind -is `skill`, looked up the same way as `thirdshift skill`. +is `skill`, looked up the same way as `thirdshift skill`. 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 skill 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. ```sh thirdshift process @@ -856,27 +908,42 @@ 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's -skill. 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's skill. 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. 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 Step uses the Command's Harness, Model and Effort: the User config -default, or the Harness default when that is unset. Each Step's final message -is stored beside the Command log. The next Step's session prompt names those -paths. The last Step's final message goes to stdout. Progress and failure -causes go to stderr. A failed Step fails the process and later Steps do not -run. Exit codes match `thirdshift skill`: `0` when the last Step finishes, -`1` when a Step or a check fails, `2` when the arguments are wrong. +default, or the Harness default when that is unset. Each finished Step's final +message is stored beside the Command log. The next Step's session prompt names +those paths. 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 final message 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. `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 kind, -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. +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`, an `optional` value that +is not a boolean, 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. Stop diff --git a/site/docs/index.html b/site/docs/index.html index 06f9219f..b4a60a9d 100644 --- a/site/docs/index.html +++ b/site/docs/index.html @@ -754,8 +754,9 @@

Custom processes

-

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

+

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

version = 1
 description = "Gather notes, then review them"
 
@@ -767,32 +768,94 @@
 name = "review"
 skill = "thirdshift-code-review"
 
+

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 one kind. This version's kind -is skill, looked up the same way as thirdshift skill.

+is skill, looked up the same way as thirdshift skill. 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 skill 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.

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's -skill. 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's skill. 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. 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 Step uses the Command's Harness, Model and Effort: the User config -default, or the Harness default when that is unset. Each Step's final message -is stored beside the Command log. The next Step's session prompt names those -paths. The last Step's final message goes to stdout. Progress and failure -causes go to stderr. A failed Step fails the process and later Steps do not -run. Exit codes match thirdshift skill: 0 when the last Step finishes, -1 when a Step or a check fails, 2 when the arguments are wrong.

+default, or the Harness default when that is unset. Each finished Step's final +message is stored beside the Command log. The next Step's session prompt names +those paths. 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 final message 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.

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 kind, -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.

+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, an optional value that +is not a boolean, 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. Stop and Ctrl-C interrupt it the way they interrupt other Commands, and diff --git a/site/how-it-works/index.html b/site/how-it-works/index.html index 22ad502b..9e294396 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, in one temporary worktree, and prints the last Step's final message. Custom processes.

+

thirdshift process is experimental, beside thirdshift skill. It runs the Steps in ~/.thirdshift/processes/<name>.toml. Consecutive Steps can run together, each in a throwaway worktree the file names, and the last Step's final message is printed. Custom processes.

Different entry points, the same implementation and Delivery
diff --git a/site/index.html b/site/index.html index cab8cf00..1f87350f 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 Steps from ~/.thirdshift/processes. Custom processes.

+

thirdshift skill runs one skill. thirdshift process is experimental: it runs skill Steps from ~/.thirdshift/processes, including Steps that run together in throwaway worktrees the file names. Custom processes.

Read every prompt and skill the agents get →

diff --git a/src/processes.rs b/src/processes.rs index df24a7f0..e40360ad 100644 --- a/src/processes.rs +++ b/src/processes.rs @@ -1,11 +1,15 @@ -//! Custom processes: sequential skill Steps from `~/.thirdshift/processes/.toml`. +//! Custom processes: skill 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; use std::fs; use std::io::ErrorKind; use std::path::{Path, PathBuf}; -use std::time::Instant; +use std::thread; +use std::time::{Duration, Instant}; use anyhow::{Context, Result, anyhow}; use toml_edit::{Document, Item, Key, Table}; @@ -31,24 +35,41 @@ const RESERVED: &[&str] = &[ struct ProcessFile { description: String, + worktrees: Vec, steps: Vec, } +struct WorktreeDraft { + name: String, +} + struct StepDraft { name: String, name_line: usize, skill: String, skill_line: usize, + worktree: Option<(String, usize)>, + parallel: Option<(String, usize)>, + optional: bool, } struct ResolvedStep { name: String, skill_name: String, skill_path: PathBuf, + worktree: Option, + parallel: Option, + optional: bool, +} + +struct OwnedWorktree { + name: Option, + checkout: SkillWorktree, } struct Prepared { - worktree: SkillWorktree, + worktrees: Vec, + declared: Vec, _written: WrittenGuard, choice: Choice, description: String, @@ -134,8 +155,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"); @@ -143,44 +166,135 @@ 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 step in &prepared.steps { - let started = Instant::now(); - match run_step(process, prepared, step, &earlier) { - Ok(message) => { - let duration = progress::duration(started.elapsed()); - 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; - } - Err(error) => { - let duration = progress::duration(started.elapsed()); - progress::step(format_args!( - "step {}: {duration} {}: {}", - step.name, - failure_outcome(), - one_line(&error) - )); - return Err(error); + for range in group_ranges(&prepared.steps) { + let group = &prepared.steps[range]; + let outcomes = if group.len() == 1 { + let started = Instant::now(); + let outcome = run_step(process, prepared, &group[0], &earlier); + vec![(started.elapsed(), outcome)] + } else { + run_parallel(process, prepared, group, &earlier) + }; + 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 Steps that share a group name. A Step with no group, and a +/// repeat of a name after a Step outside it, each start their own range. +fn group_ranges(steps: &[ResolvedStep]) -> Vec> { + let mut ranges = Vec::new(); + let mut index = 0; + while index < steps.len() { + let start = index; + if let Some(name) = &steps[index].parallel { + index += 1; + while index < steps.len() && steps[index].parallel.as_ref() == Some(name) { + index += 1; + } + } else { + index += 1; + } + ranges.push(start..index); + } + ranges +} + +fn run_parallel( + process: &str, + prepared: &Prepared, + group: &[ResolvedStep], + earlier: &[(String, PathBuf)], +) -> Vec<(Duration, Result)> { + thread::scope(|scope| { + let mut handles = Vec::with_capacity(group.len()); + for step in group { + 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(); + handles.push(scope.spawn(move || { + let started = Instant::now(); + let outcome = run_step_in(&process, &choice, &cwd, step, &earlier); + (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)], +) -> Result { + run_step_in( + process, + &prepared.choice, + worktree_path(&prepared.worktrees, step.worktree.as_deref()), + step, + earlier, + ) +} + +fn run_step_in( + process: &str, + choice: &Choice, + cwd: &Path, + step: &ResolvedStep, + earlier: &[(String, PathBuf)], ) -> Result { crate::resources::wait()?; - 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, @@ -188,12 +302,21 @@ fn run_step( &step.skill_path, earlier, ); - skill::run_session( - prepared.worktree.path(), - &prepared.choice, - &format!("Step {}", step.name), - &prompt, - ) + skill::run_session(cwd, choice, &format!("Step {}", step.name), &prompt) +} + +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) { @@ -221,39 +344,81 @@ 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 _ = writeln!( - text, - "{}: {} {}", - step.name, - step.skill_name, - step.skill_path.display() - ); + 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] { + let _ = writeln!(text, "{}", step_plan(step, named)); + } + if names.len() > 1 { + let _ = writeln!(text, "together: {}", names.join(", ")); + } } text.trim_end().to_string() } +fn step_plan(step: &ResolvedStep, named: bool) -> String { + let mut line = format!( + "{}: {} {}", + step.name, + step.skill_name, + step.skill_path.display() + ); + 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, @@ -261,20 +426,134 @@ fn prepare(name: &str, config: &UserConfig) -> Result { }) } -fn resolve(path: &Path, step: &StepDraft, worktree: &Path) -> Result { - match skills::find(worktree, &step.skill) { - Ok(skill) => Ok(ResolvedStep { - name: step.name.clone(), - skill_name: skill.name, - skill_path: skill.path, - }), - Err(error) => Err(rule( - path, - step.skill_line, - "skill not found", - format!("{error:#}"), - )), +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), + )); + } + _ => {} + } + } + let mut index = 0; + while index < file.steps.len() { + let Some(group) = file.steps[index] + .parallel + .as_ref() + .map(|(name, _)| name.clone()) + else { + index += 1; + continue; + }; + let start = index; + index += 1; + while index < file.steps.len() + && file.steps[index] + .parallel + .as_ref() + .map(|(name, _)| name.as_str()) + == Some(group.as_str()) + { + index += 1; + } + let mut seen: Vec<(&str, &str)> = Vec::new(); + for step in &file.steps[start..index] { + 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 skill = match skills::find(worktree, &step.skill) { + Ok(skill) => skill, + Err(error) => { + return Err(rule( + path, + step.skill_line, + "skill not found", + format!("{error:#}"), + )); + } + }; + Ok(ResolvedStep { + name: step.name.clone(), + skill_name: skill.name, + skill_path: skill.path, + worktree: step.worktree.as_ref().map(|(name, _)| name.clone()), + parallel: step.parallel.as_ref().map(|(name, _)| name.clone()), + optional: step.optional, + }) } fn read_process(path: &Path) -> Result { @@ -319,6 +598,7 @@ fn parse(path: &Path, text: &str) -> 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)); @@ -335,6 +615,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() { @@ -354,7 +637,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 { @@ -418,11 +782,17 @@ fn parse_steps(path: &Path, text: &str, line: usize, item: &Item) -> Result Result { let mut name = None; let mut skill = 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)); match key { "name" => name = Some(step_name(path, line, item)?), "skill" => skill = Some(step_skill(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)), } } @@ -442,9 +812,47 @@ fn parse_step(path: &Path, text: &str, table_line: usize, table: &Table) -> Resu name_line, skill, skill_line, + worktree, + parallel, + optional, }) } +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")) +} + fn step_name(path: &Path, line: usize, item: &Item) -> Result<(String, usize)> { let Some(value) = item.as_str() else { return Err(rule(path, line, "step name", "name must be a string")); diff --git a/tests/process.rs b/tests/process.rs index e2151c44..76449fed 100644 --- a/tests/process.rs +++ b/tests/process.rs @@ -591,6 +591,648 @@ 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 = \"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 == "judge" { + assert!(!prompt.contains("This is the first Step"), "{prompt}"); + } else { + assert!( + prompt.contains("This is the first Step. No earlier Step has a result."), + "{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}:")))