From b0d0d4ec1ff9c4459922e9edd75b26fa2a38ac78 Mon Sep 17 00:00:00 2001 From: Alexandr Shelevalnyk Date: Wed, 29 Jul 2026 15:54:26 +0200 Subject: [PATCH 1/8] feat(workflows): workflow template files, list/eject RPC, .warpforge config home Groundwork for configurable task workflows (PR 1 of 3): - .warpforge/workflows/*.yaml parsing and validation: fixed pipeline shape (plan? -> implement -> review <-> fix), per-stage agent/model/prompt overrides, 1..4 reviewers, review context selection, round limits with a hard cap, {{placeholder}} template rendering with per-stage variable sets - two built-in templates (review-loop, plan-review-loop) shipped in the binary; project files override built-ins by id - workflow.list / workflow.eject RPCs for the New Task picker - .warpforge/workspace.yaml becomes the preferred workspace config location; legacy root-level names keep working, generated configs land in .warpforge/ --- .changeset/calm-rivers-follow.md | 12 + crates/warpforge-protocol/src/lib.rs | 48 ++ src/config.rs | 38 +- src/daemon/server.rs | 146 +++++ src/main.rs | 9 +- src/workflow_config.rs | 858 +++++++++++++++++++++++++++ src/workflows/plan-review-loop.yaml | 17 + src/workflows/review-loop.yaml | 31 + 8 files changed, 1146 insertions(+), 13 deletions(-) create mode 100644 .changeset/calm-rivers-follow.md create mode 100644 src/workflow_config.rs create mode 100644 src/workflows/plan-review-loop.yaml create mode 100644 src/workflows/review-loop.yaml diff --git a/.changeset/calm-rivers-follow.md b/.changeset/calm-rivers-follow.md new file mode 100644 index 0000000..d11bbb6 --- /dev/null +++ b/.changeset/calm-rivers-follow.md @@ -0,0 +1,12 @@ +--- +"warpforge": minor +--- + +Adds the foundation for configurable task workflows. Projects can now define +workflow templates as YAML files in `.warpforge/workflows/` — configuring the +planning stage, reviewer agents and models, review context, and the review ⇄ +fix iteration limit — and two built-in templates (Review loop, Plan + review +loop) ship with the app and can be copied into a project for customization. +The workspace config also gains a new preferred home at +`.warpforge/workspace.yaml`: existing root-level config files keep working +unchanged, while newly generated configs land in the `.warpforge/` directory. diff --git a/crates/warpforge-protocol/src/lib.rs b/crates/warpforge-protocol/src/lib.rs index 7d4189d..69de943 100644 --- a/crates/warpforge-protocol/src/lib.rs +++ b/crates/warpforge-protocol/src/lib.rs @@ -432,6 +432,18 @@ pub enum Method { #[serde(rename = "orchestrate.saveConfig")] OrchestrateSaveConfig { config: OrchestratorConfigDto }, + // ── Workflows (deterministic pipeline templates) ── + /// List workflows selectable for a project: `.warpforge/workflows/*.yaml` + /// plus built-in templates (a project file overrides the built-in with the + /// same id). Returns `{ "workflows": [WorkflowMeta] }`. + #[serde(rename = "workflow.list")] + WorkflowList { project: String }, + /// Copy a built-in workflow into the project's `.warpforge/workflows/` + /// directory so it can be customized. Refuses to overwrite an existing + /// file. Returns `{ "path": … }`. + #[serde(rename = "workflow.eject")] + WorkflowEject { project: String, id: String }, + // ── Bootstrap wizard (desktop) ── /// Scan the repo, build the bootstrap prompt from the user's answers, and /// create a config-gen task. Returns `{ taskId }`. @@ -1315,6 +1327,42 @@ pub enum OrchNodeStatus { Skipped, } +/// One selectable workflow template, as returned by `workflow.list`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct WorkflowMeta { + pub id: String, + /// Display name from the YAML; falls back to the id for invalid files. + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + pub source: WorkflowSource, + /// False when the file failed to parse or validate — such workflows are + /// listed (greyed out in the picker, `error` in the tooltip) but cannot + /// be selected. + pub valid: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + /// Non-fatal issues (unknown keys, clamped values). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, + /// Stage names for the picker tooltip, e.g. ["plan","implement","review×2","fix"]. + /// Empty for invalid files. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub stages: Vec, + /// Review ⇄ fix round limit. 0 for invalid files. + #[serde(default)] + pub max_rounds: u32, +} + +/// Where a workflow definition comes from. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum WorkflowSource { + Project, + Builtin, +} + /// Orchestrator configuration DTO (wire format). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] diff --git a/src/config.rs b/src/config.rs index f4d3054..b57a19b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -89,8 +89,15 @@ pub fn sorted_services(config: &WorkspaceConfig) -> Vec { result } -/// Config file names in priority order: new → legacy. -const CONFIG_NAMES: &[&str] = &[".warpforge.yaml", ".wf.yaml", ".workspace.yaml"]; +/// Config file names in priority order: new → legacy. `.warpforge/` is the +/// preferred home for warpforge files (workspace config, workflows); the +/// root-level names keep working for existing projects. +const CONFIG_NAMES: &[&str] = &[ + ".warpforge/workspace.yaml", + ".warpforge.yaml", + ".wf.yaml", + ".workspace.yaml", +]; /// Load a project's config while preserving the distinction between a missing /// config and an existing file that could not be read or parsed. @@ -115,7 +122,9 @@ pub fn load_workspace_config(project_path: &Path) -> Option { try_load_workspace_config(project_path).ok().flatten() } -/// Return the first existing config file path, or the default `.warpforge.yaml`. +/// Return the first existing config file path, or the default +/// `.warpforge/workspace.yaml` for projects that have no config yet. Callers +/// writing to the returned path must create its parent directory first. pub fn find_config_file(project_path: &Path) -> std::path::PathBuf { for name in CONFIG_NAMES { let p = project_path.join(name); @@ -123,7 +132,7 @@ pub fn find_config_file(project_path: &Path) -> std::path::PathBuf { return p; } } - project_path.join(".warpforge.yaml") + project_path.join(CONFIG_NAMES[0]) } fn auto_detect(project_path: &Path) -> Option { @@ -219,13 +228,17 @@ fn auto_detect(project_path: &Path) -> Option { }) } -/// Generate a .warpforge.yaml file in the given directory. -/// If auto-detection finds services, pre-populates them. +/// Generate a `.warpforge/workspace.yaml` file in the given directory. +/// If auto-detection finds services, pre-populates them. Refuses to run when +/// any config (new or legacy location) already exists. pub fn generate_workspace_yaml(project_path: &Path) -> anyhow::Result<()> { - let target = project_path.join(".warpforge.yaml"); - if target.exists() { - anyhow::bail!(".warpforge.yaml already exists at {}", target.display()); + for name in CONFIG_NAMES { + let existing = project_path.join(name); + if existing.exists() { + anyhow::bail!("config already exists at {}", existing.display()); + } } + let target = project_path.join(CONFIG_NAMES[0]); let name = project_path .canonicalize() @@ -238,11 +251,11 @@ pub fn generate_workspace_yaml(project_path: &Path) -> anyhow::Result<()> { let content = if let Some(config) = auto_detect(project_path) { // Serialize detected config let yaml = serde_yaml::to_string(&config)?; - format!("# .warpforge.yaml — auto-detected by warpforge\n{yaml}") + format!("# .warpforge/workspace.yaml — auto-detected by warpforge\n{yaml}") } else { // Write template format!( - r#"# .warpforge.yaml — Warpforge project configuration + r#"# .warpforge/workspace.yaml — Warpforge project configuration name: {name} services: @@ -263,6 +276,9 @@ services: ) }; + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; + } fs::write(&target, content)?; println!("Created {}", target.display()); Ok(()) diff --git a/src/daemon/server.rs b/src/daemon/server.rs index 762ddb8..3f7e6f9 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -831,6 +831,25 @@ async fn dispatch( let ok = rx.await.unwrap_or(false); Ok(json!({ "ok": ok })) } + // ── Workflows ── + WorkflowList { project } => { + let path = project_path(handle, &project).await?; + let workflows: Vec = + crate::workflow_config::list_workflows(std::path::Path::new(&path)) + .into_iter() + .map(workflow_meta) + .collect(); + Ok(json!({ "workflows": workflows })) + } + WorkflowEject { project, id } => { + let path = project_path(handle, &project).await?; + let target = crate::workflow_config::eject_builtin(std::path::Path::new(&path), &id) + .map_err(|e| wire::RpcError { + code: wire::ErrorCode::InvalidRequest, + message: format!("{e:#}"), + })?; + Ok(json!({ "path": target.to_string_lossy() })) + } ProjectAdd { path, name } => { let entry = handle .add_project(&path, name.as_deref()) @@ -899,6 +918,12 @@ async fn dispatch( BootstrapWriteConfig { project, yaml } => { let path = project_path(handle, &project).await?; let target = crate::config::find_config_file(std::path::Path::new(&path)); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent).map_err(|e| wire::RpcError { + code: wire::ErrorCode::Internal, + message: format!("create {}: {e}", parent.display()), + })?; + } std::fs::write(&target, yaml).map_err(|e| wire::RpcError { code: wire::ErrorCode::Internal, message: format!("write {}: {e}", target.display()), @@ -926,6 +951,38 @@ fn validate_issues(yaml: &str) -> Vec { } } +/// Wire form of one workflow definition for the New Task picker. +fn workflow_meta(w: crate::workflow_config::LoadedWorkflow) -> wire::WorkflowMeta { + let source = match w.source { + crate::workflow_config::WorkflowSource::Project => wire::WorkflowSource::Project, + crate::workflow_config::WorkflowSource::Builtin => wire::WorkflowSource::Builtin, + }; + match w.spec { + Ok(spec) => wire::WorkflowMeta { + id: w.id, + name: spec.name.clone(), + description: spec.description.clone(), + source, + valid: true, + error: None, + warnings: w.warnings, + stages: spec.stage_summary(), + max_rounds: spec.review.max_rounds, + }, + Err(error) => wire::WorkflowMeta { + name: w.id.clone(), + id: w.id, + description: None, + source, + valid: false, + error: Some(error), + warnings: w.warnings, + stages: vec![], + max_rounds: 0, + }, + } +} + /// Resolve a registered project's directory, or an `InvalidRequest` error. async fn project_path(handle: &DaemonHandle, project: &str) -> Result { handle @@ -991,6 +1048,7 @@ fn method_is_mutation(method: &wire::Method) -> bool { | GitPushInfo { .. } | OrchestrateList {} | OrchestrateGetConfig {} + | WorkflowList { .. } | BootstrapFinalize { .. } | BootstrapReadConfig { .. } ) @@ -1091,6 +1149,94 @@ mod tests { assert!(saw_response, "expected a response with a taskId"); } + #[tokio::test] + async fn workflow_list_and_eject_over_websocket() { + type Ws = tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >; + async fn rpc( + ws: &mut Ws, + id: u64, + method: &str, + params: serde_json::Value, + ) -> serde_json::Value { + ws.send(Message::Text( + json!({ "id": id, "method": method, "params": params }).to_string(), + )) + .await + .unwrap(); + loop { + let msg = timeout(Duration::from_secs(2), ws.next()) + .await + .expect("frame") + .expect("some") + .expect("ok"); + if let Message::Text(t) = msg { + let v: serde_json::Value = serde_json::from_str(t.as_str()).unwrap(); + if v.get("id").and_then(|i| i.as_u64()) == Some(id) { + return v; + } + } + } + } + + let dir = tempfile::tempdir().unwrap(); + let projects = vec![ProjectEntry { + name: "demo".into(), + path: dir.path().to_string_lossy().into_owned(), + added_at: "0".into(), + }]; + let store = Store::open_at(std::path::Path::new(":memory:")).ok(); + let handle = Daemon::spawn(projects, store); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(run(listener, handle.clone(), String::new())); + let (mut ws, _) = tokio_tungstenite::connect_async(format!("ws://{addr}")) + .await + .unwrap(); + + // A fresh project sees exactly the built-in templates. + let v = rpc(&mut ws, 1, "workflow.list", json!({ "project": "demo" })).await; + let workflows = v["result"]["workflows"].as_array().unwrap(); + assert_eq!(workflows.len(), 2); + assert!(workflows + .iter() + .all(|w| w["source"] == "builtin" && w["valid"] == true)); + assert!(workflows.iter().any(|w| w["id"] == "review-loop")); + + // Ejecting copies the built-in into .warpforge/workflows/. + let v = rpc( + &mut ws, + 2, + "workflow.eject", + json!({ "project": "demo", "id": "review-loop" }), + ) + .await; + let path = v["result"]["path"].as_str().unwrap(); + assert!(std::path::Path::new(path).exists()); + + // The ejected copy now overrides the built-in… + let v = rpc(&mut ws, 3, "workflow.list", json!({ "project": "demo" })).await; + let workflows = v["result"]["workflows"].as_array().unwrap(); + let review = workflows.iter().find(|w| w["id"] == "review-loop").unwrap(); + assert_eq!(review["source"], "project"); + + // …and a second eject refuses to overwrite it. + let v = rpc( + &mut ws, + 4, + "workflow.eject", + json!({ "project": "demo", "id": "review-loop" }), + ) + .await; + assert!(v["error"]["message"].as_str().unwrap().contains("exists")); + + // Unknown projects are rejected. + let v = rpc(&mut ws, 5, "workflow.list", json!({ "project": "nope" })).await; + assert!(v.get("error").is_some()); + } + #[tokio::test] async fn spawn_terminal_streams_screen_over_websocket() { let projects = vec![ProjectEntry { diff --git a/src/main.rs b/src/main.rs index 97cad53..4ea839d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,7 @@ mod ports; mod registry; mod service; mod tui; +mod workflow_config; use anyhow::{anyhow, Context, Result}; use clap::{Parser, Subcommand}; @@ -40,7 +41,7 @@ enum Commands { Remove { name: String }, /// List registered projects List, - /// Generate .warpforge.yaml in the current (or given) directory + /// Generate .warpforge/workspace.yaml in the current (or given) directory Init { /// Directory to init (defaults to current directory) path: Option, @@ -48,7 +49,7 @@ enum Commands { #[arg(short, long)] add: bool, }, - /// Interactively generate a .warpforge.yaml with an agent. Registers the + /// Interactively generate a workspace config with an agent. Registers the /// project if needed, asks the daemon to run a bootstrap task, then lets you /// accept / edit / discard the proposed config. Bootstrap { @@ -269,6 +270,10 @@ async fn run_bootstrap(path: &str) -> Result<()> { .starts_with('y') { let target = config::find_config_file(std::path::Path::new(path)); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating {}", parent.display()))?; + } std::fs::write(&target, &yaml).with_context(|| format!("writing {}", target.display()))?; println!("Wrote {}", target.display()); } else { diff --git a/src/workflow_config.rs b/src/workflow_config.rs new file mode 100644 index 0000000..cb7c2c3 --- /dev/null +++ b/src/workflow_config.rs @@ -0,0 +1,858 @@ +//! Workflow templates: `.warpforge/workflows/*.yaml` parsing and validation, +//! `{{placeholder}}` prompt-template rendering, and the built-in templates +//! shipped with the binary. +//! +//! The pipeline shape is fixed (`plan? → implement → review ⇄ fix`), so a +//! workflow file configures the fixed stages rather than declaring arbitrary +//! ones. +//! +//! This module is deliberately independent of daemon internals: the daemon's +//! workflow engine consumes [`WorkflowSpec`], and everything here is plain +//! sync code unit-testable in isolation. + +use anyhow::{bail, Context, Result}; +use serde::Deserialize; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// The only workflow file format version this build understands. +pub const SUPPORTED_VERSION: u64 = 1; +/// Hard cap on review ⇄ fix rounds a YAML may request (a human can still +/// extend a running pipeline past this — that is an explicit decision). +pub const MAX_ROUNDS_CAP: u32 = 5; +pub const DEFAULT_MAX_ROUNDS: u32 = 2; +pub const MAX_REVIEWERS: usize = 4; + +/// Built-in templates, selectable everywhere and ejectable into a project. +/// A project file with the same id overrides (hides) the built-in. +pub const BUILTIN_WORKFLOWS: &[(&str, &str)] = &[ + ("review-loop", include_str!("workflows/review-loop.yaml")), + ( + "plan-review-loop", + include_str!("workflows/plan-review-loop.yaml"), + ), +]; + +// ─── Validated model ───────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq)] +pub struct WorkflowSpec { + /// File stem for project workflows, registry key for built-ins. + pub id: String, + pub name: String, + pub description: Option, + /// `Some` when the optional planning stage is enabled. + pub plan: Option, + pub implement: StageConfig, + pub review: ReviewConfig, + pub fix: StageConfig, +} + +/// Per-stage overrides. `None` falls back to the lead agent / model picked in +/// the New Task dialog (for `fix`: to the `implement` stage's values) and to +/// the built-in stage prompt. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct StageConfig { + pub agent: Option, + pub model: Option, + pub prompt: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ReviewConfig { + pub max_rounds: u32, + pub on_limit: OnLimit, + pub context: Vec, + /// Always 1..=MAX_REVIEWERS entries; defaults to one all-`None` reviewer. + pub reviewers: Vec, +} + +/// What the pipeline does when `max_rounds` is exhausted with open findings. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OnLimit { + /// Suspend and ask the user (extend / finish / stop). + Ask, + /// Finish as NeedsReview with the open findings in the summary. + Finish, +} + +/// One piece of context assembled into a reviewer's prompt. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReviewContextItem { + /// The task prompt the user typed. + Prompt, + /// The plan stage output, when that stage ran. + Plan, + /// The final text of the last implement/fix session. + ImplementerSummary, + /// The working-copy diff. + Diff, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ReviewerConfig { + pub agent: Option, + pub model: Option, + /// Appended to the default reviewer prompt (available as `{{focus}}`). + pub focus: Option, + /// Full prompt override for this reviewer. + pub prompt: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorkflowSource { + Project, + Builtin, +} + +/// A workflow as found on disk (or built in): invalid files are carried as +/// `Err` so pickers can list them greyed-out with the reason. +#[derive(Debug)] +pub struct LoadedWorkflow { + pub id: String, + pub source: WorkflowSource, + pub spec: Result, + pub warnings: Vec, +} + +impl WorkflowSpec { + /// Stage names for picker tooltips, e.g. `["plan", "implement", "review×2", "fix"]`. + pub fn stage_summary(&self) -> Vec { + let mut stages = Vec::new(); + if self.plan.is_some() { + stages.push("plan".to_string()); + } + stages.push("implement".to_string()); + stages.push(match self.review.reviewers.len() { + 1 => "review".to_string(), + n => format!("review×{n}"), + }); + stages.push("fix".to_string()); + stages + } +} + +// ─── Raw YAML shape ────────────────────────────────────────────────────────── + +/// Deserialize an optional mapping so that a bare `plan:` (YAML null) means +/// "present with defaults", while an absent key stays `None`. +fn nullable<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de> + Default, +{ + let value = Option::::deserialize(deserializer)?; + Ok(Some(value.unwrap_or_default())) +} + +#[derive(Debug, Default, Deserialize)] +struct RawWorkflow { + version: Option, + name: Option, + description: Option, + #[serde(default, deserialize_with = "nullable")] + plan: Option, + #[serde(default, deserialize_with = "nullable")] + implement: Option, + #[serde(default, deserialize_with = "nullable")] + review: Option, + #[serde(default, deserialize_with = "nullable")] + fix: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct RawStage { + agent: Option, + model: Option, + prompt: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct RawReview { + max_rounds: Option, + on_limit: Option, + context: Option>, + reviewers: Option>, +} + +#[derive(Debug, Default, Deserialize)] +struct RawReviewer { + agent: Option, + model: Option, + focus: Option, + prompt: Option, +} + +// ─── Parsing and validation ────────────────────────────────────────────────── + +/// Parse and validate one workflow file. Returns the spec (or a human-readable +/// error making the file invalid) plus non-fatal warnings either way. +pub fn parse_workflow(id: &str, yaml: &str) -> (Result, Vec) { + let mut warnings = Vec::new(); + let value: serde_yaml::Value = match serde_yaml::from_str(yaml) { + Ok(value) => value, + Err(e) => return (Err(format!("invalid YAML: {e}")), warnings), + }; + if !value.is_mapping() { + return ( + Err("workflow file must be a YAML mapping".to_string()), + warnings, + ); + } + collect_unknown_keys(&value, &mut warnings); + let raw: RawWorkflow = match serde_yaml::from_value(value) { + Ok(raw) => raw, + Err(e) => return (Err(format!("invalid workflow: {e}")), warnings), + }; + (build_spec(id, raw, &mut warnings), warnings) +} + +/// Unknown keys are forward-compatible warnings, not errors — `workflow.list` +/// surfaces them in picker tooltips. +fn collect_unknown_keys(value: &serde_yaml::Value, warnings: &mut Vec) { + const TOP: &[&str] = &[ + "version", + "name", + "description", + "plan", + "implement", + "review", + "fix", + ]; + const STAGE: &[&str] = &["agent", "model", "prompt"]; + const REVIEW: &[&str] = &["max_rounds", "on_limit", "context", "reviewers"]; + const REVIEWER: &[&str] = &["agent", "model", "focus", "prompt"]; + + check_keys(value, TOP, "top level", warnings); + for stage in ["plan", "implement", "fix"] { + if let Some(v) = value.get(stage) { + check_keys(v, STAGE, stage, warnings); + } + } + if let Some(review) = value.get("review") { + check_keys(review, REVIEW, "review", warnings); + if let Some(serde_yaml::Value::Sequence(reviewers)) = review.get("reviewers") { + for (i, reviewer) in reviewers.iter().enumerate() { + check_keys( + reviewer, + REVIEWER, + &format!("review.reviewers[{i}]"), + warnings, + ); + } + } + } +} + +fn check_keys( + value: &serde_yaml::Value, + known: &[&str], + location: &str, + warnings: &mut Vec, +) { + let serde_yaml::Value::Mapping(map) = value else { + return; + }; + for key in map.keys() { + if let Some(key) = key.as_str() { + if !known.contains(&key) { + warnings.push(format!("unknown key `{key}` in {location}")); + } + } + } +} + +fn build_spec( + id: &str, + raw: RawWorkflow, + warnings: &mut Vec, +) -> Result { + let version = raw.version.unwrap_or(SUPPORTED_VERSION); + if version != SUPPORTED_VERSION { + return Err(format!( + "unsupported workflow version {version} (this build supports {SUPPORTED_VERSION})" + )); + } + let name = raw.name.as_deref().map(str::trim).unwrap_or_default(); + if name.is_empty() { + return Err("`name` is required".to_string()); + } + + let plan = raw.plan.map(stage_config); + let implement = raw.implement.map(stage_config).unwrap_or_default(); + let fix = raw.fix.map(stage_config).unwrap_or_default(); + let review = build_review(raw.review.unwrap_or_default(), warnings)?; + + validate_prompt( + plan.as_ref().and_then(|s| s.prompt.as_deref()), + "plan", + VARS_PLAN, + )?; + validate_prompt(implement.prompt.as_deref(), "implement", VARS_IMPLEMENT)?; + validate_prompt(fix.prompt.as_deref(), "fix", VARS_FIX)?; + for (i, reviewer) in review.reviewers.iter().enumerate() { + validate_prompt( + reviewer.prompt.as_deref(), + &format!("review.reviewers[{i}]"), + VARS_REVIEW, + )?; + } + + Ok(WorkflowSpec { + id: id.to_string(), + name: name.to_string(), + description: raw + .description + .map(|d| d.trim().to_string()) + .filter(|d| !d.is_empty()), + plan, + implement, + review, + fix, + }) +} + +fn stage_config(raw: RawStage) -> StageConfig { + StageConfig { + agent: raw.agent, + model: raw.model, + prompt: raw.prompt, + } +} + +fn build_review(raw: RawReview, warnings: &mut Vec) -> Result { + let mut max_rounds = raw.max_rounds.unwrap_or(DEFAULT_MAX_ROUNDS); + if max_rounds == 0 { + return Err("review.max_rounds must be at least 1".to_string()); + } + if max_rounds > MAX_ROUNDS_CAP { + warnings.push(format!( + "review.max_rounds {max_rounds} exceeds the cap, clamped to {MAX_ROUNDS_CAP}" + )); + max_rounds = MAX_ROUNDS_CAP; + } + + let on_limit = match raw.on_limit.as_deref() { + None => OnLimit::Ask, + Some("ask") => OnLimit::Ask, + Some("finish") => OnLimit::Finish, + Some(other) => { + return Err(format!( + "review.on_limit must be `ask` or `finish`, got `{other}`" + )) + } + }; + + let context = match raw.context { + None => vec![ + ReviewContextItem::Prompt, + ReviewContextItem::Plan, + ReviewContextItem::ImplementerSummary, + ReviewContextItem::Diff, + ], + Some(items) => { + if items.is_empty() { + warnings.push( + "review.context is empty — reviewers will only see their instructions" + .to_string(), + ); + } + let mut parsed = Vec::with_capacity(items.len()); + for item in &items { + parsed.push(match item.as_str() { + "prompt" => ReviewContextItem::Prompt, + "plan" => ReviewContextItem::Plan, + "implementer_summary" => ReviewContextItem::ImplementerSummary, + "diff" => ReviewContextItem::Diff, + other => { + return Err(format!( + "unknown review.context item `{other}` (expected prompt, plan, implementer_summary, diff)" + )) + } + }); + } + parsed + } + }; + + let reviewers = match raw.reviewers { + None => vec![ReviewerConfig::default()], + Some(list) => { + if list.is_empty() || list.len() > MAX_REVIEWERS { + return Err(format!( + "review.reviewers must list 1..{MAX_REVIEWERS} reviewers when set, got {}", + list.len() + )); + } + list.into_iter() + .map(|r| ReviewerConfig { + agent: r.agent, + model: r.model, + focus: r.focus, + prompt: r.prompt, + }) + .collect() + } + }; + + Ok(ReviewConfig { + max_rounds, + on_limit, + context, + reviewers, + }) +} + +// ─── Prompt templates ──────────────────────────────────────────────────────── + +/// Placeholders each stage's custom prompt may use. An unknown placeholder is +/// a validation error — silently rendering `{{typo}}` verbatim into an agent +/// prompt would be far harder to notice. +pub const VARS_PLAN: &[&str] = &["task_prompt"]; +pub const VARS_IMPLEMENT: &[&str] = &["task_prompt", "plan"]; +pub const VARS_REVIEW: &[&str] = &[ + "task_prompt", + "plan", + "implementer_summary", + "diff", + "round", + "max_rounds", + "focus", +]; +pub const VARS_FIX: &[&str] = &[ + "task_prompt", + "plan", + "implementer_summary", + "diff", + "findings", + "round", + "max_rounds", +]; + +fn validate_prompt(prompt: Option<&str>, stage: &str, allowed: &[&str]) -> Result<(), String> { + let Some(prompt) = prompt else { + return Ok(()); + }; + for name in extract_placeholders(prompt) { + if !allowed.contains(&name.as_str()) { + return Err(format!( + "unknown placeholder {{{{{name}}}}} in {stage} prompt (allowed: {})", + allowed.join(", ") + )); + } + } + Ok(()) +} + +/// `{{name}}` occurrences as (start, end-exclusive, trimmed name). Only +/// identifier-shaped names count; other `{{…}}` text is left alone. +fn scan_placeholders(template: &str) -> Vec<(usize, usize, String)> { + let mut found = Vec::new(); + let bytes = template.as_bytes(); + let mut i = 0; + while let Some(open) = template[i..].find("{{").map(|p| i + p) { + let Some(close) = template[open + 2..].find("}}").map(|p| open + 2 + p) else { + break; + }; + let name = template[open + 2..close].trim(); + let is_ident = + !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'); + if is_ident { + found.push((open, close + 2, name.to_string())); + i = close + 2; + } else { + // Skip just the opening braces so `{{ {{diff}}` still finds the + // inner placeholder. + i = open + 2; + } + if i >= bytes.len() { + break; + } + } + found +} + +/// Distinct placeholder names used in a template, in order of first use. +pub fn extract_placeholders(template: &str) -> Vec { + let mut seen = HashSet::new(); + scan_placeholders(template) + .into_iter() + .map(|(_, _, name)| name) + .filter(|name| seen.insert(name.clone())) + .collect() +} + +/// Substitute `{{name}}` placeholders. Names missing from `vars` are left +/// verbatim (validation has already rejected unknown names at load time). +/// Consumed by the workflow engine when it renders stage prompts. +#[allow(dead_code)] +pub fn render_template(template: &str, vars: &HashMap<&str, String>) -> String { + let mut out = String::with_capacity(template.len()); + let mut last = 0; + for (start, end, name) in scan_placeholders(template) { + if let Some(value) = vars.get(name.as_str()) { + out.push_str(&template[last..start]); + out.push_str(value); + last = end; + } + } + out.push_str(&template[last..]); + out +} + +// ─── Disk access ───────────────────────────────────────────────────────────── + +pub fn workflows_dir(project_path: &Path) -> PathBuf { + project_path.join(".warpforge").join("workflows") +} + +/// All workflows visible to a project: `.warpforge/workflows/*.{yaml,yml}` +/// (sorted by file name; on duplicate stems the first file wins) followed by +/// built-ins not overridden by a project file with the same id. +pub fn list_workflows(project_path: &Path) -> Vec { + let mut out: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + + let mut files: Vec = fs::read_dir(workflows_dir(project_path)) + .map(|entries| { + entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| { + matches!( + path.extension().and_then(|e| e.to_str()), + Some("yaml") | Some("yml") + ) + }) + .collect() + }) + .unwrap_or_default(); + files.sort(); + + for path in files { + let Some(id) = path + .file_stem() + .and_then(|s| s.to_str()) + .map(str::to_string) + else { + continue; + }; + let file_name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + if !seen.insert(id.clone()) { + if let Some(existing) = out.iter_mut().find(|w| w.id == id) { + existing + .warnings + .push(format!("duplicate workflow file ignored: {file_name}")); + } + continue; + } + let (spec, warnings) = match fs::read_to_string(&path) { + Ok(text) => parse_workflow(&id, &text), + Err(e) => (Err(format!("reading {file_name}: {e}")), Vec::new()), + }; + out.push(LoadedWorkflow { + id, + source: WorkflowSource::Project, + spec, + warnings, + }); + } + + for (id, text) in BUILTIN_WORKFLOWS { + if seen.contains(*id) { + continue; + } + let (spec, warnings) = parse_workflow(id, text); + out.push(LoadedWorkflow { + id: (*id).to_string(), + source: WorkflowSource::Builtin, + spec, + warnings, + }); + } + + out +} + +/// Load one workflow by id: the project file wins over a built-in. +/// Consumed by the workflow engine when a task starts with a workflow. +#[allow(dead_code)] +pub fn load_workflow(project_path: &Path, id: &str) -> Option { + list_workflows(project_path) + .into_iter() + .find(|w| w.id == id) +} + +/// Copy a built-in workflow into the project's workflows directory so the +/// user can customize it. Refuses to overwrite an existing file. +pub fn eject_builtin(project_path: &Path, id: &str) -> Result { + let Some((_, text)) = BUILTIN_WORKFLOWS.iter().find(|(bid, _)| *bid == id) else { + bail!("no built-in workflow `{id}`"); + }; + let dir = workflows_dir(project_path); + fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?; + for ext in ["yaml", "yml"] { + let existing = dir.join(format!("{id}.{ext}")); + if existing.exists() { + bail!("{} already exists", existing.display()); + } + } + let target = dir.join(format!("{id}.yaml")); + fs::write(&target, text).with_context(|| format!("writing {}", target.display()))?; + Ok(target) +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_ok(yaml: &str) -> (WorkflowSpec, Vec) { + let (spec, warnings) = parse_workflow("test", yaml); + (spec.expect("expected valid workflow"), warnings) + } + + fn parse_err(yaml: &str) -> String { + let (spec, _) = parse_workflow("test", yaml); + spec.expect_err("expected invalid workflow") + } + + #[test] + fn minimal_workflow_gets_defaults() { + let (spec, warnings) = parse_ok("name: Minimal\n"); + assert!(warnings.is_empty()); + assert_eq!(spec.name, "Minimal"); + assert!(spec.plan.is_none()); + assert_eq!(spec.implement, StageConfig::default()); + assert_eq!(spec.review.max_rounds, DEFAULT_MAX_ROUNDS); + assert_eq!(spec.review.on_limit, OnLimit::Ask); + assert_eq!(spec.review.reviewers, vec![ReviewerConfig::default()]); + assert_eq!( + spec.review.context, + vec![ + ReviewContextItem::Prompt, + ReviewContextItem::Plan, + ReviewContextItem::ImplementerSummary, + ReviewContextItem::Diff, + ] + ); + assert_eq!(spec.stage_summary(), vec!["implement", "review", "fix"]); + } + + #[test] + fn builtins_are_valid() { + for (id, text) in BUILTIN_WORKFLOWS { + let (spec, warnings) = parse_workflow(id, text); + let spec = spec.unwrap_or_else(|e| panic!("built-in `{id}` invalid: {e}")); + assert!( + warnings.is_empty(), + "built-in `{id}` has warnings: {warnings:?}" + ); + assert_eq!(spec.id, *id); + } + } + + #[test] + fn bare_plan_key_enables_stage() { + assert!(parse_ok("name: X\n").0.plan.is_none()); + assert_eq!( + parse_ok("name: X\nplan:\n").0.plan, + Some(StageConfig::default()) + ); + assert_eq!( + parse_ok("name: X\nplan: {}\n").0.plan, + Some(StageConfig::default()) + ); + let (spec, _) = parse_ok("name: X\nplan:\n agent: codex\n"); + assert_eq!(spec.plan.unwrap().agent.as_deref(), Some("codex")); + } + + #[test] + fn full_workflow_parses() { + let yaml = r#" +version: 1 +name: Feature loop +description: Cross-review with two models. +plan: + agent: claude +implement: + agent: claude + model: claude-fable-5 +review: + max_rounds: 3 + on_limit: finish + context: [prompt, diff] + reviewers: + - agent: claude + model: claude-opus-5 + focus: correctness + - agent: codex + prompt: "Review this: {{diff}} for round {{round}}/{{max_rounds}}" +fix: + agent: claude +"#; + let (spec, warnings) = parse_ok(yaml); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(spec.review.max_rounds, 3); + assert_eq!(spec.review.on_limit, OnLimit::Finish); + assert_eq!( + spec.review.context, + vec![ReviewContextItem::Prompt, ReviewContextItem::Diff] + ); + assert_eq!(spec.review.reviewers.len(), 2); + assert_eq!(spec.review.reviewers[1].agent.as_deref(), Some("codex")); + assert_eq!( + spec.stage_summary(), + vec!["plan", "implement", "review×2", "fix"] + ); + } + + #[test] + fn unknown_keys_warn_but_stay_valid() { + let yaml = "name: X\nfuture_thing: 1\nreview:\n gate: build\n reviewers:\n - agent: claude\n voice: loud\n"; + let (spec, warnings) = parse_workflow("test", yaml); + assert!(spec.is_ok()); + assert_eq!( + warnings, + vec![ + "unknown key `future_thing` in top level", + "unknown key `gate` in review", + "unknown key `voice` in review.reviewers[0]", + ] + ); + } + + #[test] + fn validation_errors() { + assert!(parse_err("services: {}\n").contains("`name` is required")); + assert!(parse_err("name: X\nversion: 2\n").contains("unsupported workflow version 2")); + assert!(parse_err("name: X\nreview:\n max_rounds: 0\n").contains("at least 1")); + assert!(parse_err("name: X\nreview:\n on_limit: retry\n").contains("`ask` or `finish`")); + assert!( + parse_err("name: X\nreview:\n context: [prompt, everything]\n") + .contains("unknown review.context item `everything`") + ); + assert!(parse_err("name: X\nreview:\n reviewers: []\n").contains("1..4 reviewers")); + let five = + "name: X\nreview:\n reviewers:\n - {}\n - {}\n - {}\n - {}\n - {}\n"; + assert!(parse_err(five).contains("1..4 reviewers")); + assert!(parse_workflow("test", ": : :") + .0 + .unwrap_err() + .contains("invalid YAML")); + } + + #[test] + fn max_rounds_clamped_with_warning() { + let (spec, warnings) = parse_ok("name: X\nreview:\n max_rounds: 9\n"); + assert_eq!(spec.review.max_rounds, MAX_ROUNDS_CAP); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("clamped to 5")); + } + + #[test] + fn unknown_placeholder_is_an_error() { + let err = parse_err("name: X\nimplement:\n prompt: \"Do {{taks_prompt}}\"\n"); + assert!( + err.contains("unknown placeholder {{taks_prompt}} in implement prompt"), + "{err}" + ); + // `{{diff}}` is a review/fix variable, not an implement one. + let err = parse_err("name: X\nimplement:\n prompt: \"See {{diff}}\"\n"); + assert!(err.contains("{{diff}}"), "{err}"); + // Reviewer prompts may use the review variable set, including {{focus}}. + let yaml = "name: X\nreview:\n reviewers:\n - prompt: \"{{focus}}: check {{diff}}\"\n"; + assert!(parse_workflow("test", yaml).0.is_ok()); + } + + #[test] + fn placeholder_extraction_and_rendering() { + assert_eq!( + extract_placeholders("{{a}} and {{ b }} and {{a}} but not {{a b}} or {{}}"), + vec!["a", "b"] + ); + let vars = HashMap::from([ + ("task_prompt", "fix the bug".to_string()), + ("round", "2".to_string()), + ]); + assert_eq!( + render_template( + "Task: {{task_prompt}} (round {{ round }}) {{unknown}}", + &vars + ), + "Task: fix the bug (round 2) {{unknown}}" + ); + assert_eq!(render_template("no placeholders", &vars), "no placeholders"); + } + + #[test] + fn listing_project_overrides_builtin_and_reports_invalid() { + let dir = tempfile::tempdir().unwrap(); + let wf_dir = workflows_dir(dir.path()); + fs::create_dir_all(&wf_dir).unwrap(); + // Overrides the built-in of the same id. + fs::write(wf_dir.join("review-loop.yaml"), "name: Mine\n").unwrap(); + // Invalid file still listed. + fs::write(wf_dir.join("broken.yaml"), "name: [\n").unwrap(); + // Non-workflow files ignored. + fs::write(wf_dir.join("notes.txt"), "hi").unwrap(); + + let listed = list_workflows(dir.path()); + let ids: Vec<(&str, WorkflowSource, bool)> = listed + .iter() + .map(|w| (w.id.as_str(), w.source, w.spec.is_ok())) + .collect(); + assert_eq!( + ids, + vec![ + ("broken", WorkflowSource::Project, false), + ("review-loop", WorkflowSource::Project, true), + ("plan-review-loop", WorkflowSource::Builtin, true), + ] + ); + let mine = load_workflow(dir.path(), "review-loop").unwrap(); + assert_eq!(mine.spec.unwrap().name, "Mine"); + } + + #[test] + fn listing_without_workflows_dir_returns_builtins() { + let dir = tempfile::tempdir().unwrap(); + let listed = list_workflows(dir.path()); + assert_eq!(listed.len(), BUILTIN_WORKFLOWS.len()); + assert!(listed.iter().all(|w| w.source == WorkflowSource::Builtin)); + } + + #[test] + fn duplicate_stems_warn_and_first_wins() { + let dir = tempfile::tempdir().unwrap(); + let wf_dir = workflows_dir(dir.path()); + fs::create_dir_all(&wf_dir).unwrap(); + fs::write(wf_dir.join("loop.yaml"), "name: From yaml\n").unwrap(); + fs::write(wf_dir.join("loop.yml"), "name: From yml\n").unwrap(); + + let listed = list_workflows(dir.path()); + let entry = listed.iter().find(|w| w.id == "loop").unwrap(); + assert_eq!(entry.spec.as_ref().unwrap().name, "From yaml"); + assert_eq!( + entry.warnings, + vec!["duplicate workflow file ignored: loop.yml"] + ); + } + + #[test] + fn eject_writes_once() { + let dir = tempfile::tempdir().unwrap(); + let path = eject_builtin(dir.path(), "review-loop").unwrap(); + assert!(path.ends_with(".warpforge/workflows/review-loop.yaml")); + let text = fs::read_to_string(&path).unwrap(); + assert!(parse_workflow("review-loop", &text).0.is_ok()); + // Second eject refuses to overwrite. + assert!(eject_builtin(dir.path(), "review-loop").is_err()); + assert!(eject_builtin(dir.path(), "nope").is_err()); + } +} diff --git a/src/workflows/plan-review-loop.yaml b/src/workflows/plan-review-loop.yaml new file mode 100644 index 0000000..a87439f --- /dev/null +++ b/src/workflows/plan-review-loop.yaml @@ -0,0 +1,17 @@ +# Built-in workflow: plan → implement → review ⇄ fix. +# +# Copy this file into /.warpforge/workflows/ (or use "Copy to +# project" in the New Task dialog) to customize it. Every key except `name` +# is optional — omitted stages fall back to the lead agent picked in the +# dialog and to the built-in stage prompts. +version: 1 +name: Plan + review loop +description: Plan first, then implement and loop reviews and fixes until approved. + +plan: {} # presence of this key enables the planning stage (a bare `plan:` works too) +# agent: claude +# model: claude-opus-5 + +review: + max_rounds: 2 # review ⇄ fix iterations before asking you what to do + on_limit: ask # ask | finish — behaviour when the limit is hit diff --git a/src/workflows/review-loop.yaml b/src/workflows/review-loop.yaml new file mode 100644 index 0000000..cc6e389 --- /dev/null +++ b/src/workflows/review-loop.yaml @@ -0,0 +1,31 @@ +# Built-in workflow: implement → review ⇄ fix. +# +# Copy this file into /.warpforge/workflows/ (or use "Copy to +# project" in the New Task dialog) to customize it. Every key except `name` +# is optional — omitted stages fall back to the lead agent picked in the +# dialog and to the built-in stage prompts. +version: 1 +name: Review loop +description: Implement, then loop reviews and fixes until reviewers approve. + +# implement: # defaults to the lead agent from the dialog +# agent: claude +# model: claude-opus-5 +# prompt: | # custom stage prompt, supports {{task_prompt}}, {{plan}} +# ... + +review: + max_rounds: 2 # review ⇄ fix iterations before asking you what to do + on_limit: ask # ask | finish — behaviour when the limit is hit + # context: [prompt, plan, implementer_summary, diff] + # reviewers: # defaults to one reviewer = the lead agent + # - agent: claude + # model: claude-opus-5 + # focus: correctness and edge cases + # - agent: codex + # focus: security and error handling + +# fix: # defaults to the implement stage's agent/model +# agent: claude +# prompt: | # supports {{findings}}, {{diff}}, {{round}}, … +# ... From 5a704995596e697cddf359201c55f6e9aeb7ab9e Mon Sep 17 00:00:00 2001 From: Alexandr Shelevalnyk Date: Wed, 29 Jul 2026 16:32:55 +0200 Subject: [PATCH 2/8] feat(workflows): deterministic pipeline engine in the daemon PR 2 of 3: task.create with a workflow id now drives plan? -> implement -> review <-> fix as a state machine inside the daemon actor. - engine: stages spawn as child tasks with rendered prompts; parallel reviewers return fenced-JSON verdicts (one re-ask on garbage, then fail); findings merge with unanimous-approve semantics; low-severity findings are deferred to the summary instead of the fixer - decision points: stages can suspend the run with a need_user_input marker (workflow.reply routes the answer to the asking session, or re-runs the stage with the Q/A as guidance when that session is gone); hitting the review limit asks extend/finish/stop (workflow.decide) with optional guidance for the next fix round - soft pause at stage barriers (workflow.pause/resume, resume notes become stage guidance); parent Stop/archive/delete stops the whole pipeline - persistence: workflow_runs table snapshots the run on every transition; after a restart barrier states continue as-is and mid-stage runs park as Paused at their last barrier (resume re-runs the stage) - the parent task has no agent session: its transcript is a synthetic timeline, TaskInfo gains workflow_run (stage/round/verdict/waiting), and orchestration_graph finally gets a producer for the board accordion - protocol: workflow param on task.create; workflow.pause/resume/reply/decide; WorkflowRunInfo/WorkflowWaiting wire types; OrchNodeKind::Fix - tests: engine unit tests plus end-to-end mock-agent pipelines (reject-> fix->approve loop, question/reply, limit decision, pause/resume, restart recovery) via a scripted mock ACP agent fixture --- .changeset/eager-stages-march.md | 15 + crates/warpforge-protocol/src/lib.rs | 123 +++ src/daemon/actor.rs | 1136 +++++++++++++++++++++++- src/daemon/mod.rs | 341 +++++++ src/daemon/server.rs | 77 ++ src/daemon/store.rs | 38 + src/daemon/task.rs | 4 + src/daemon/wire.rs | 1 + src/daemon/workflow.rs | 1220 ++++++++++++++++++++++++++ src/workflow_config.rs | 16 +- tests/fixtures/mock-acp-workflow.mjs | 88 ++ 11 files changed, 3036 insertions(+), 23 deletions(-) create mode 100644 .changeset/eager-stages-march.md create mode 100644 src/daemon/workflow.rs create mode 100644 tests/fixtures/mock-acp-workflow.mjs diff --git a/.changeset/eager-stages-march.md b/.changeset/eager-stages-march.md new file mode 100644 index 0000000..ff19b8c --- /dev/null +++ b/.changeset/eager-stages-march.md @@ -0,0 +1,15 @@ +--- +"warpforge": minor +--- + +Workflow pipelines now actually run. Creating a task with a workflow selected +drives it through a deterministic plan → implement → review ⇄ fix pipeline: +each stage is a child agent session, reviewers run in parallel and return a +structured verdict, and fix rounds are hard-capped by the workflow's limit. +The parent task narrates progress as a timeline, and the pipeline suspends +for you when it needs input: a stage can ask a question mid-run, and hitting +the review limit asks whether to grant more rounds, finish as is, or stop — +optionally with extra guidance for the next fix. Pipelines can be paused at a +stage boundary and resumed (with notes), survive daemon restarts by parking +at their last safe point, and never commit anything: a finished run lands in +Needs review for you to inspect and commit. diff --git a/crates/warpforge-protocol/src/lib.rs b/crates/warpforge-protocol/src/lib.rs index 69de943..245d7bf 100644 --- a/crates/warpforge-protocol/src/lib.rs +++ b/crates/warpforge-protocol/src/lib.rs @@ -205,6 +205,13 @@ pub enum Method { /// after the model. Unknown option ids are logged and skipped. #[serde(default)] config_overrides: HashMap, + /// When set, run this task as a deterministic workflow pipeline: the + /// created task becomes the pipeline parent (no agent session of its + /// own) and the daemon drives plan? → implement → review ⇄ fix stages + /// as child tasks. The id comes from `workflow.list`. Mutually + /// exclusive with the orchestrator-chat mode. + #[serde(default)] + workflow: Option, }, #[serde(rename = "task.cancel")] TaskCancel { task_id: String }, @@ -443,6 +450,38 @@ pub enum Method { /// file. Returns `{ "path": … }`. #[serde(rename = "workflow.eject")] WorkflowEject { project: String, id: String }, + /// Soft-pause a running workflow pipeline: the current stage finishes its + /// turn, the next stage does not start. Errors when the pipeline is not + /// in a pausable state (already waiting for the user, or finished). + #[serde(rename = "workflow.pause")] + WorkflowPause { task: String }, + /// Resume a paused pipeline from its stage barrier. `note`, when set, is + /// delivered to the next stage as an extra "User guidance" block. + #[serde(rename = "workflow.resume")] + WorkflowResume { + task: String, + #[serde(default)] + note: Option, + }, + /// Answer a stage's pending `need_user_input` question. The message is + /// forwarded verbatim to the session that asked. Errors unless the + /// pipeline is waiting on a question. + #[serde(rename = "workflow.reply")] + WorkflowReply { task: String, message: String }, + /// Decide what an out-of-rounds pipeline does next. Errors unless the + /// pipeline is waiting on a limit decision. + #[serde(rename = "workflow.decide")] + WorkflowDecide { + task: String, + decision: WorkflowDecision, + /// For `extend`: how many extra review ⇄ fix rounds to grant (1..=5, + /// default 1). + #[serde(default)] + rounds: Option, + /// Optional extra guidance delivered to the next fix stage. + #[serde(default)] + note: Option, + }, // ── Bootstrap wizard (desktop) ── /// Scan the repo, build the bootstrap prompt from the user's answers, and @@ -758,6 +797,9 @@ pub struct TaskInfo { /// on the wire lets clients present the child in its parent's context. #[serde(default, skip_serializing_if = "Option::is_none")] pub parent_task_id: Option, + /// Live workflow pipeline state for workflow parent tasks. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workflow_run: Option, /// Explicit settle override (true = settled, false = not settled). /// `None` = derive from execution status only (no manual override). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1315,6 +1357,8 @@ pub enum OrchNodeKind { Implement, Review, Merge, + /// Workflow-pipeline repair stage (fix findings from a review round). + Fix, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -1363,6 +1407,85 @@ pub enum WorkflowSource { Builtin, } +/// A `workflow.decide` choice after review rounds are exhausted. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum WorkflowDecision { + /// Grant extra review ⇄ fix rounds and continue. + Extend, + /// Finish as NeedsReview with the open findings in the summary. + Finish, + /// Stop the pipeline (parent becomes Interrupted). + Stop, +} + +/// Live state of a workflow pipeline, carried on the parent task and updated +/// via `task.updated` events. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct WorkflowRunInfo { + pub workflow_id: String, + pub workflow_name: String, + pub stage: WorkflowStage, + /// Current review round, 1-based; 0 until the first review starts. + pub round: u32, + /// Effective round limit: the YAML `max_rounds` plus user-granted + /// extensions. + pub max_rounds: u32, + /// Latest merged review verdict. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub verdict: Option, + /// Present while the pipeline waits for the user — the parent composer + /// opens on this, and it drives the attention ("Needs you") rail. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub waiting: Option, +} + +/// Pipeline position for display. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkflowStage { + Plan, + Implement, + Review, + Fix, + Done, + Failed, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkflowVerdict { + Approve, + RequestChanges, +} + +/// Why a pipeline is suspended and what input unblocks it. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct WorkflowWaiting { + pub kind: WorkflowWaitKind, + /// Which stage asked (for `question`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stage: Option, + /// The question text (for `question`), or a short findings summary (for + /// `limit`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub question: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkflowWaitKind { + /// A stage asked `need_user_input` — answer with `workflow.reply`. + Question, + /// Review rounds exhausted with open findings — answer with + /// `workflow.decide`. + Limit, + /// Soft-paused — continue with `workflow.resume`. + Paused, +} + /// Orchestrator configuration DTO (wire format). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] diff --git a/src/daemon/actor.rs b/src/daemon/actor.rs index e266775..87a8aad 100644 --- a/src/daemon/actor.rs +++ b/src/daemon/actor.rs @@ -35,6 +35,9 @@ use super::acp::{spawn_acp_session, AcpHandle, AcpUpdate, PolicyCheck}; use super::store::Store; use super::task::{Task, TaskStatus}; use super::wire as wireconv; +use super::workflow::{ + self, RunState, StageKind, StageSignal, Verdict, WorkflowOutcome, WorkflowRun, +}; use super::worktree::WorktreeManager; use crate::policies::builtins::{BlastRadiusPolicy, SpawnBoundsPolicy}; use crate::policies::registry::PolicyRegistry; @@ -569,6 +572,45 @@ pub enum Command { config_overrides: std::collections::HashMap, reply: oneshot::Sender, }, + /// Create a workflow-pipeline parent task and start its first stage. + /// Unlike `CreateTask` the parent gets no agent session of its own — the + /// daemon drives stages as child tasks. + CreateWorkflowTask { + project: String, + prompt: String, + agent: String, + tags: Vec, + worktree: bool, + workflow: String, + attachments: Vec, + default_model: Option, + reply: oneshot::Sender>, + }, + /// Soft-pause a workflow pipeline at its next stage barrier. + WorkflowPause { + task: String, + reply: oneshot::Sender>, + }, + /// Resume a paused workflow pipeline. + WorkflowResume { + task: String, + note: Option, + reply: oneshot::Sender>, + }, + /// Answer a workflow stage's pending `need_user_input` question. + WorkflowReply { + task: String, + message: String, + reply: oneshot::Sender>, + }, + /// Decide what an out-of-rounds workflow pipeline does next. + WorkflowDecide { + task: String, + decision: wire::WorkflowDecision, + rounds: Option, + note: Option, + reply: oneshot::Sender>, + }, /// Drain an orchestrator task's inbox of finished sub-agent results. ReadInbox { parent_task_id: String, @@ -1495,6 +1537,9 @@ pub struct Daemon { pending_wake: std::collections::HashSet, /// Stable first-seen timestamps for streamed frames of the same tool call. tool_call_starts: HashMap<(String, String), u64>, + /// Deterministic workflow pipelines keyed by parent task id. Finished runs + /// stay in the map so their state remains visible on the board. + workflow_runs: HashMap, } impl Daemon { @@ -1596,6 +1641,7 @@ impl Daemon { orchestrator_inbox: HashMap::new(), pending_wake: std::collections::HashSet::new(), tool_call_starts, + workflow_runs: HashMap::new(), }; let handle = DaemonHandle { cmd_tx, event_tx }; @@ -1629,6 +1675,9 @@ impl Daemon { let mut daemon = daemon; daemon.orch_tx = Some(orch_cmd_tx); daemon.orch_event_rx = None; // receiver moved to forwarder task + // Bring persisted workflow pipelines back: barrier states as-is, + // mid-stage runs paused at their last barrier. + daemon.restore_workflow_runs(); tokio::spawn(daemon.run(cmd_rx, agent_rx, service_rx, pf_rx, acp_rx, policy_rx)); @@ -1893,7 +1942,7 @@ impl Daemon { Some(ev) = agent_rx.recv() => self.handle_agent_event(ev), Some(ev) = service_rx.recv() => self.handle_service_event(ev), Some(ev) = pf_rx.recv() => self.handle_pf_event(ev), - Some((task_id, update)) = acp_rx.recv() => self.handle_acp_update(task_id, update), + Some((task_id, update)) = acp_rx.recv() => self.handle_acp_update(task_id, update).await, Some(check) = policy_rx.recv() => self.handle_policy_check(check).await, _ = config_poll.tick() => self.handle_config_changes().await, } @@ -2363,6 +2412,53 @@ impl Daemon { config_overrides, ); } + Command::CreateWorkflowTask { + project, + prompt, + agent, + tags, + worktree, + workflow, + attachments, + default_model, + reply, + } => { + let result = self + .workflow_create( + project, + prompt, + agent, + tags, + worktree, + workflow, + attachments, + default_model, + ) + .await; + let _ = reply.send(result); + } + Command::WorkflowPause { task, reply } => { + let _ = reply.send(self.workflow_pause(&task)); + } + Command::WorkflowResume { task, note, reply } => { + let _ = reply.send(self.workflow_resume(&task, note).await); + } + Command::WorkflowReply { + task, + message, + reply, + } => { + let _ = reply.send(self.workflow_reply(&task, message).await); + } + Command::WorkflowDecide { + task, + decision, + rounds, + note, + reply, + } => { + let _ = reply.send(self.workflow_decide(&task, decision, rounds, note).await); + } Command::ReadInbox { parent_task_id, reply, @@ -2682,18 +2778,30 @@ impl Daemon { } } Command::CancelTask { id } => { - if let Some(handle) = self.sessions.remove(&id) { - handle.cancel(); - } - self.pending_permissions.cleanup_task(&id); - if let Some(task) = self.tasks.get_mut(&id) { - task.set_status(TaskStatus::Idle); - let updated = task.clone(); - self.persist(&updated); - self.emit(Event::TaskUpdated(updated)); + if self.workflow_is_active(&id) { + // Stopping a workflow parent stops the whole pipeline. + self.workflow_finalize(&id, WorkflowOutcome::Stopped).await; + } else { + if let Some(handle) = self.sessions.remove(&id) { + handle.cancel(); + } + self.pending_permissions.cleanup_task(&id); + if let Some(task) = self.tasks.get_mut(&id) { + task.set_status(TaskStatus::Idle); + let updated = task.clone(); + self.persist(&updated); + self.emit(Event::TaskUpdated(updated)); + } + // Cancelling a stage child mid-run fails that stage. + self.workflow_child_gone(&id).await; } } Command::ArchiveTask { id } => { + // Archiving a live workflow parent stops the pipeline first so + // no orphaned stage sessions keep running behind a Done task. + if self.workflow_is_active(&id) { + self.workflow_finalize(&id, WorkflowOutcome::Stopped).await; + } // Collect children that reference this task as parent so we // can archive them together with the leader. let child_ids: Vec = self @@ -2722,6 +2830,14 @@ impl Daemon { } } Command::DeleteTask { id } => { + if self.workflow_is_active(&id) { + self.workflow_finalize(&id, WorkflowOutcome::Stopped).await; + } + if self.workflow_runs.remove(&id).is_some() { + if let Some(store) = &self.store { + let _ = store.delete_workflow_run(&id); + } + } if let Some(handle) = self.sessions.remove(&id) { handle.cancel(); } @@ -2742,7 +2858,9 @@ impl Daemon { if let Some(store) = &self.store { let _ = store.delete_task(&id); } - self.emit(Event::TaskRemoved { id }); + self.emit(Event::TaskRemoved { id: id.clone() }); + // Deleting a stage child mid-run fails that stage. + self.workflow_child_gone(&id).await; } } Command::SetTaskTitle { id, title } => { @@ -3662,7 +3780,7 @@ impl Daemon { } } - fn handle_acp_update(&mut self, task_id: String, update: AcpUpdate) { + async fn handle_acp_update(&mut self, task_id: String, update: AcpUpdate) { match update { AcpUpdate::SessionStarted { session_id } => { if let Some(task) = self.tasks.get_mut(&task_id) { @@ -3800,9 +3918,19 @@ impl Daemon { } let output = self.collect_agent_text(&task_id); self.notify_orch_finished(&task_id, success, output.clone()); - // Deliver to a parent if this was a sub-agent; and drain our own - // inbox if we are a parent that just went idle. - self.deliver_child_result(&task_id, success, output); + if self.workflow_child_of(&task_id).is_some() { + // A workflow stage finished — advance the pipeline. Parse + // only the latest turn's text: answered questions and + // superseded verdicts from earlier turns must not count. + // The legacy orchestrator inbox path does not apply here. + let last_turn = self.collect_last_turn_text(&task_id); + self.workflow_stage_finished(&task_id, success, last_turn) + .await; + } else { + // Deliver to a parent if this was a sub-agent; and drain our + // own inbox if we are a parent that just went idle. + self.deliver_child_result(&task_id, success, output); + } // If we are an orchestrator whose sub-agents finished mid-turn, // process them now that the turn is over. if self.pending_wake.remove(&task_id) { @@ -3829,7 +3957,11 @@ impl Daemon { self.emit(Event::TaskUpdated(updated)); } self.notify_orch_finished(&task_id, false, reason.clone()); - self.deliver_child_result(&task_id, false, reason); + if self.workflow_child_of(&task_id).is_some() { + self.workflow_stage_finished(&task_id, false, reason).await; + } else { + self.deliver_child_result(&task_id, false, reason); + } } } } @@ -3928,6 +4060,29 @@ impl Daemon { .join("") } + /// Like [`collect_agent_text`], but only the text streamed since the last + /// user message — i.e. the output of the task's latest turn. The workflow + /// engine parses this: a `need_user_input` block answered two turns ago + /// must not be mistaken for a fresh question. + fn collect_last_turn_text(&self, task_id: &str) -> String { + let Some(updates) = self + .store + .as_ref() + .and_then(|s| s.load_session_updates(task_id).ok()) + else { + return String::new(); + }; + let mut texts: Vec = Vec::new(); + for update in updates { + match update { + wire::SessionUpdate::UserMessage { .. } => texts.clear(), + wire::SessionUpdate::AgentText { text } => texts.push(text), + _ => {} + } + } + texts.join("") + } + /// Tell the orchestrator a dispatched task finished. No-op unless the task /// carries the "orchestrator" tag and an orchestrator is wired. fn notify_orch_finished(&self, task_id: &str, success: bool, result: String) { @@ -4145,6 +4300,955 @@ impl Daemon { } } +// ─── Workflow pipeline engine (actor glue) ─────────────────────────────────── +// +// The deterministic `plan? → implement → review ⇄ fix` pipeline. Pure logic +// (state container, prompt building, verdict parsing, review merging) lives in +// `daemon/workflow.rs`; these methods are the side-effectful glue: they spawn +// stage child tasks, react to their turn ends, and narrate progress into the +// parent task's transcript. +// +// Borrow discipline: methods that mutate a run *and* call `&mut self` helpers +// temporarily remove the run from `workflow_runs` and re-insert it (the +// take/put pattern). Every exit path must re-insert. +impl Daemon { + fn workflow_is_active(&self, task_id: &str) -> bool { + self.workflow_runs + .get(task_id) + .is_some_and(WorkflowRun::is_active) + } + + /// The parent task id of an *active* pipeline this child belongs to. + /// Searches the runs (not the tasks map) so it also works for tasks that + /// were just removed. + fn workflow_child_of(&self, child_id: &str) -> Option { + self.workflow_runs + .values() + .find(|run| { + run.is_active() + && (run.active_children.contains_key(child_id) + || run.review_pending.contains_key(child_id)) + }) + .map(|run| run.parent_id.clone()) + } + + /// Append a narration line to the parent's transcript. The parent has no + /// agent session — these synthetic entries *are* its timeline. + fn workflow_timeline(&self, parent_id: &str, text: impl Into) { + self.emit_session( + parent_id, + wire::SessionUpdate::AgentText { text: text.into() }, + ); + } + + /// Sync the parent task's `workflow_run` + `orchestration_graph` + /// projections from the run, persist, and broadcast; also persist the run. + fn workflow_sync(&mut self, run: &WorkflowRun) { + if let Some(task) = self.tasks.get_mut(&run.parent_id) { + task.workflow_run = Some(run.wire_info()); + task.orchestration_graph = Some(run.graph_info()); + task.updated_at = super::task::now_secs(); + let updated = task.clone(); + self.persist(&updated); + self.emit(Event::TaskUpdated(updated)); + } + if let Some(store) = &self.store { + if let Ok(json) = serde_json::to_string(run) { + let _ = store.save_workflow_run(&run.parent_id, &json); + } + } + } + + /// `CreateWorkflowTask`: validate the workflow, create the parent task + /// (without an agent session), and start the first stage. + #[allow(clippy::too_many_arguments)] + async fn workflow_create( + &mut self, + project: String, + prompt: String, + agent: String, + tags: Vec, + use_worktree: bool, + workflow_id: String, + attachments: Vec, + default_model: Option, + ) -> Result { + let path = self + .project_path(&project) + .ok_or_else(|| format!("unknown project '{project}'"))?; + let loaded = + crate::workflow_config::load_workflow(std::path::Path::new(&path), &workflow_id) + .ok_or_else(|| format!("unknown workflow `{workflow_id}`"))?; + let spec = loaded + .spec + .map_err(|e| format!("workflow `{workflow_id}` is invalid: {e}"))?; + + let mut tags = tags; + tags.push(format!("workflow:{workflow_id}")); + let mut task = Task::new(&project, &prompt, &agent, tags); + if use_worktree { + let wt_mgr = self + .worktrees + .entry(project.clone()) + .or_insert_with(|| WorktreeManager::new(std::path::PathBuf::from(&path))); + match wt_mgr.create(&task.id, None).await { + Ok(wt) => task.worktree = Some(wt.path.to_string_lossy().to_string()), + Err(e) => eprintln!("[daemon] worktree creation failed: {e}"), + } + } + // The parent is "running" for the whole life of the pipeline. + task.set_status(TaskStatus::Running); + let resolved_model = default_model.or_else(|| { + self.configured_agents + .iter() + .find(|a| a.id == agent) + .and_then(|a| a.last_model.clone()) + }); + let parent_id = task.id.clone(); + self.tasks.insert(parent_id.clone(), task.clone()); + self.persist(&task); + self.emit(Event::TaskCreated(task)); + + let run = WorkflowRun::new( + parent_id.clone(), + project, + spec, + agent, + resolved_model, + attachments, + ); + self.workflow_timeline( + &parent_id, + format!( + "Workflow **{}** started (stages: {}; up to {} review rounds).", + run.spec.name, + run.spec.stage_summary().join(" → "), + run.effective_max_rounds(), + ), + ); + let first = run.first_stage(); + self.workflow_runs.insert(parent_id.clone(), run); + self.workflow_spawn_stage(&parent_id, first).await; + Ok(parent_id) + } + + /// Spawn the child task(s) for a stage and mark the run as running it. + async fn workflow_spawn_stage(&mut self, parent_id: &str, stage: StageKind) { + let Some(mut run) = self.workflow_runs.remove(parent_id) else { + return; + }; + let Some(parent) = self.tasks.get(parent_id) else { + self.workflow_runs.insert(parent_id.to_string(), run); + return; + }; + let parent_prompt = parent.prompt.clone(); + let parent_title = parent.title.clone(); + let worktree = parent.worktree.clone(); + let project = run.project.clone(); + + if stage == StageKind::Review { + run.round += 1; + } + let guidance = match stage { + StageKind::Review => None, + _ => run.take_guidance(), + }; + // Review and fix stages see the current working-copy diff. + let diff = match stage { + StageKind::Review | StageKind::Fix => { + let dir = worktree.clone().or_else(|| self.project_path(&project)); + match dir { + Some(dir) => match super::diff::working_diff(&dir).await { + Ok(files) => Some(workflow::format_diff(&files)), + Err(e) => Some(format!("(diff unavailable: {e})")), + }, + None => None, + } + } + _ => None, + }; + let ctx = workflow::PromptCtx { + task_prompt: parent_prompt, + plan: run.plan_output.clone(), + implementer_summary: run.last_summary.as_deref().map(workflow::clip_summary), + diff, + findings: match stage { + StageKind::Fix => Some(workflow::format_findings(&run.open_findings)), + _ => None, + }, + round: run.round, + max_rounds: run.effective_max_rounds(), + guidance, + }; + // The dialog's attachments ride along with the very first stage only. + let attachments = if run.history.is_empty() { + std::mem::take(&mut run.attachments) + } else { + Vec::new() + }; + + run.state = RunState::Running { stage }; + match stage { + StageKind::Review => { + run.review_pending.clear(); + run.review_collected.clear(); + run.reasked.clear(); + let round_label = format!("round {}/{}", run.round, run.effective_max_rounds()); + for index in 0..run.spec.review.reviewers.len() { + let (agent, model) = run.stage_agent(stage, Some(index)); + let prompt = workflow::build_reviewer_prompt(&run.spec, index, &ctx); + let label = run.reviewer_label(index); + let child_id = self.workflow_spawn_child( + &run.project, + parent_id, + &agent, + model, + prompt, + worktree.clone(), + format!("review · {parent_title}"), + Vec::new(), + ); + run.review_pending.insert(child_id.clone(), index); + run.active_children.insert(child_id.clone(), stage); + run.record_stage(stage, &child_id, &agent, format!("{label}, {round_label}")); + } + self.workflow_timeline( + parent_id, + format!( + "Review {round_label} started — {} reviewer(s) running.", + run.review_pending.len() + ), + ); + } + _ => { + let (agent, model) = run.stage_agent(stage, None); + let prompt = match stage { + StageKind::Plan => workflow::build_plan_prompt(&run.spec, &ctx), + StageKind::Implement => workflow::build_implement_prompt(&run.spec, &ctx), + StageKind::Fix => workflow::build_fix_prompt(&run.spec, &ctx), + StageKind::Review => unreachable!(), + }; + let child_id = self.workflow_spawn_child( + &run.project, + parent_id, + &agent, + model.clone(), + prompt, + worktree.clone(), + format!("{} · {parent_title}", stage.label()), + attachments, + ); + run.active_children.insert(child_id.clone(), stage); + let label = match stage { + StageKind::Fix => format!("{} (round {})", stage.label(), run.round), + _ => stage.label().to_string(), + }; + run.record_stage(stage, &child_id, &agent, label); + self.workflow_timeline( + parent_id, + format!( + "Stage **{}** started — {agent}{}.", + stage.label(), + model.map(|m| format!(" ({m})")).unwrap_or_default() + ), + ); + } + } + self.workflow_sync(&run); + self.workflow_runs.insert(parent_id.to_string(), run); + } + + /// Create and start one stage child task. Children run in the parent's + /// directory (its worktree when isolated) but are NOT registered in the + /// worktree manager — the parent owns the worktree's lifecycle. + #[allow(clippy::too_many_arguments)] + fn workflow_spawn_child( + &mut self, + project: &str, + parent_id: &str, + agent: &str, + model: Option, + prompt: String, + worktree: Option, + title: String, + attachments: Vec, + ) -> String { + let mut task = Task::new(project, &prompt, agent, vec!["workflow-stage".to_string()]); + task.parent_task_id = Some(parent_id.to_string()); + task.worktree = worktree; + task.title = title; + let child_id = task.id.clone(); + self.tasks.insert(child_id.clone(), task.clone()); + self.persist(&task); + self.emit(Event::TaskCreated(task)); + self.start_session( + &child_id, + project, + agent, + &prompt, + false, + None, + attachments, + model, + HashMap::new(), + ); + child_id + } + + /// A stage child's turn ended (or its session died). Advance the pipeline. + async fn workflow_stage_finished(&mut self, child_id: &str, success: bool, output: String) { + let Some(parent_id) = self.workflow_child_of(child_id) else { + return; + }; + let Some(mut run) = self.workflow_runs.remove(&parent_id) else { + return; + }; + let stage = match run.active_children.get(child_id) { + Some(stage) => *stage, + None => { + self.workflow_runs.insert(parent_id.clone(), run); + return; + } + }; + // Only a running stage advances the pipeline: a turn that ends while + // we await a reply is the answered child continuing, handled below. + let running_stage = matches!(run.state, RunState::Running { stage: s } if s == stage); + let awaiting_this_child = + matches!(&run.state, RunState::AwaitingReply { child, .. } if child == child_id); + if !running_stage && !awaiting_this_child { + self.workflow_runs.insert(parent_id.clone(), run); + return; + } + + if !success { + self.workflow_child_failed(&parent_id, run, child_id, stage) + .await; + return; + } + + match stage { + StageKind::Review => { + self.workflow_review_finished(&parent_id, run, child_id, output) + .await; + } + StageKind::Plan | StageKind::Implement | StageKind::Fix => { + match workflow::parse_stage_signal(&output) { + StageSignal::Question(question) => { + run.state = RunState::AwaitingReply { + stage, + child: child_id.to_string(), + question: question.clone(), + }; + self.workflow_timeline( + &parent_id, + format!( + "Stage **{}** needs your input:\n\n> {question}\n\nAnswer in this chat to continue.", + stage.label() + ), + ); + self.workflow_sync(&run); + self.workflow_runs.insert(parent_id.clone(), run); + } + StageSignal::Output => { + run.active_children.remove(child_id); + run.set_record_status(child_id, wire::OrchNodeStatus::Complete); + match stage { + StageKind::Plan => run.plan_output = Some(output), + _ => run.last_summary = Some(output), + } + self.workflow_timeline( + &parent_id, + format!("Stage **{}** finished.", stage.label()), + ); + let next = stage.successor().unwrap_or(StageKind::Review); + self.workflow_runs.insert(parent_id.clone(), run); + self.workflow_advance(&parent_id, next).await; + } + } + } + } + } + + /// A non-review stage child failed, or a reviewer died. Reviewers are + /// excluded from the verdict; any other stage failure fails the pipeline. + async fn workflow_child_failed( + &mut self, + parent_id: &str, + mut run: WorkflowRun, + child_id: &str, + stage: StageKind, + ) { + run.set_record_status(child_id, wire::OrchNodeStatus::Failed); + if stage == StageKind::Review { + let index = run.review_pending.remove(child_id); + run.active_children.remove(child_id); + run.reasked.remove(child_id); + let label = index + .map(|i| run.reviewer_label(i)) + .unwrap_or_else(|| "reviewer".to_string()); + self.workflow_timeline( + parent_id, + format!("{label} failed — excluded from this round's verdict."), + ); + if run.review_pending.is_empty() { + if run.review_collected.is_empty() { + self.workflow_runs.insert(parent_id.to_string(), run); + self.workflow_finalize( + parent_id, + WorkflowOutcome::Error("all reviewers failed".to_string()), + ) + .await; + } else { + self.workflow_merge_reviews(parent_id, run).await; + } + } else { + self.workflow_sync(&run); + self.workflow_runs.insert(parent_id.to_string(), run); + } + return; + } + let reason = self + .tasks + .get(child_id) + .and_then(|t| t.blocked_reason.clone()) + .unwrap_or_else(|| "agent session ended unexpectedly".to_string()); + self.workflow_runs.insert(parent_id.to_string(), run); + self.workflow_finalize( + parent_id, + WorkflowOutcome::Error(format!("stage {} failed: {reason}", stage.label())), + ) + .await; + } + + /// One reviewer's turn ended: parse its verdict, re-ask once on garbage, + /// and merge the round when every reviewer has resolved. + async fn workflow_review_finished( + &mut self, + parent_id: &str, + mut run: WorkflowRun, + child_id: &str, + output: String, + ) { + let Some(index) = run.review_pending.get(child_id).copied() else { + self.workflow_runs.insert(parent_id.to_string(), run); + return; + }; + let label = run.reviewer_label(index); + match workflow::parse_review_verdict(&output, &label) { + Ok((verdict, findings)) => { + run.review_pending.remove(child_id); + run.active_children.remove(child_id); + run.set_record_status(child_id, wire::OrchNodeStatus::Complete); + run.review_collected.push((index, verdict, findings)); + if run.review_pending.is_empty() { + self.workflow_merge_reviews(parent_id, run).await; + } else { + self.workflow_sync(&run); + self.workflow_runs.insert(parent_id.to_string(), run); + } + } + Err(reason) => { + let asked = run.reasked.entry(child_id.to_string()).or_insert(0); + if *asked < workflow::MAX_VERDICT_REASKS { + *asked += 1; + let reask = workflow::reask_verdict_prompt(&reason); + let delivered = self + .sessions + .get(child_id) + .map(|handle| { + handle + .prompt(super::prompt::PreparedPrompt { + content: vec![super::prompt::PromptContent::Text( + reask.clone(), + )], + summaries: vec![], + has_images: false, + }) + .is_ok() + }) + .unwrap_or(false); + if delivered { + self.emit_session( + child_id, + wire::SessionUpdate::UserMessage { + text: reask, + attachments: vec![], + }, + ); + self.mark_task_running(child_id); + self.workflow_timeline( + parent_id, + format!("{label} returned no parseable verdict — asking again."), + ); + self.workflow_runs.insert(parent_id.to_string(), run); + return; + } + // Dead session: fall through to the failure path. + } + self.workflow_runs.insert(parent_id.to_string(), run); + self.workflow_finalize( + parent_id, + WorkflowOutcome::Error(format!( + "{label} returned no parseable verdict after a retry ({reason})" + )), + ) + .await; + } + } + } + + /// All reviewers of a round resolved: merge, then approve / fix / limit. + async fn workflow_merge_reviews(&mut self, parent_id: &str, mut run: WorkflowRun) { + let (verdict, findings) = workflow::merge_reviews(&run.review_collected); + run.review_collected.clear(); + run.last_verdict = Some(verdict); + let (to_fix, low): (Vec<_>, Vec<_>) = + findings.into_iter().partition(|f| f.severity.goes_to_fix()); + run.deferred_findings.extend(low); + match verdict { + Verdict::Approve => { + self.workflow_timeline( + parent_id, + format!("Review round {}: **approved**.", run.round), + ); + run.open_findings.clear(); + self.workflow_runs.insert(parent_id.to_string(), run); + self.workflow_finalize(parent_id, WorkflowOutcome::Success { limit_hit: false }) + .await; + } + Verdict::RequestChanges if to_fix.is_empty() => { + // Changes requested but every finding is low-severity — there + // is nothing for the fixer to do. Finish with notes. + self.workflow_timeline( + parent_id, + format!( + "Review round {}: changes requested, but only low-severity notes remain — finishing.", + run.round + ), + ); + run.open_findings.clear(); + self.workflow_runs.insert(parent_id.to_string(), run); + self.workflow_finalize(parent_id, WorkflowOutcome::Success { limit_hit: false }) + .await; + } + Verdict::RequestChanges => { + run.open_findings = to_fix; + self.workflow_timeline( + parent_id, + format!( + "Review round {}: **changes requested** — {}.\n\n{}", + run.round, + workflow::summarize_findings(&run.open_findings), + workflow::format_findings(&run.open_findings), + ), + ); + if run.round < run.effective_max_rounds() { + self.workflow_runs.insert(parent_id.to_string(), run); + self.workflow_advance(parent_id, StageKind::Fix).await; + } else { + match run.spec.review.on_limit { + crate::workflow_config::OnLimit::Ask => { + run.state = RunState::AwaitingLimitDecision; + self.workflow_timeline( + parent_id, + format!( + "Review limit reached ({} rounds) with {}. What next — extend \ + the rounds, finish as is, or stop? You can add guidance for \ + the next fix attempt.", + run.effective_max_rounds(), + workflow::summarize_findings(&run.open_findings), + ), + ); + self.workflow_sync(&run); + self.workflow_runs.insert(parent_id.to_string(), run); + } + crate::workflow_config::OnLimit::Finish => { + self.workflow_runs.insert(parent_id.to_string(), run); + self.workflow_finalize( + parent_id, + WorkflowOutcome::Success { limit_hit: true }, + ) + .await; + } + } + } + } + } + } + + /// Stage barrier: honour a pending pause request, otherwise start `next`. + async fn workflow_advance(&mut self, parent_id: &str, next: StageKind) { + let paused = { + let Some(run) = self.workflow_runs.get_mut(parent_id) else { + return; + }; + if run.pause_requested { + run.pause_requested = false; + run.state = RunState::Paused { next }; + true + } else { + false + } + }; + if paused { + self.workflow_timeline( + parent_id, + format!( + "Paused before stage **{}**. Resume to continue; you can add guidance.", + next.label() + ), + ); + let run = self.workflow_runs.get(parent_id).cloned(); + if let Some(run) = run { + self.workflow_sync(&run); + } + } else { + self.workflow_spawn_stage(parent_id, next).await; + } + } + + /// Kill any still-running stage children (their sessions), Idle them. + fn workflow_stop_children(&mut self, run: &mut WorkflowRun) { + let children: Vec = run.active_children.keys().cloned().collect(); + for child_id in children { + if let Some(handle) = self.sessions.remove(&child_id) { + handle.cancel(); + } + self.pending_permissions.cleanup_task(&child_id); + run.set_record_status(&child_id, wire::OrchNodeStatus::Skipped); + if let Some(task) = self.tasks.get_mut(&child_id) { + if task.status == TaskStatus::Running || task.status == TaskStatus::Queued { + task.set_status(TaskStatus::Idle); + let updated = task.clone(); + self.persist(&updated); + self.emit(Event::TaskUpdated(updated)); + } + } + } + run.active_children.clear(); + run.review_pending.clear(); + } + + /// End the pipeline: stop children, write the summary, set the parent's + /// final status. + async fn workflow_finalize(&mut self, parent_id: &str, outcome: WorkflowOutcome) { + let Some(mut run) = self.workflow_runs.remove(parent_id) else { + return; + }; + if !run.is_active() { + self.workflow_runs.insert(parent_id.to_string(), run); + return; + } + self.workflow_stop_children(&mut run); + + let mut summary = String::new(); + let rounds_used = run.round; + match &outcome { + WorkflowOutcome::Success { limit_hit } => { + run.state = RunState::Done; + summary.push_str(&format!( + "Workflow **{}** finished after {rounds_used} review round(s).", + run.spec.name + )); + if *limit_hit { + summary.push_str(&format!( + "\n\n⚠ Review limit reached with unresolved findings:\n{}", + workflow::format_findings(&run.open_findings) + )); + } + if !run.deferred_findings.is_empty() { + summary.push_str(&format!( + "\n\nLow-severity notes from review (not auto-fixed):\n{}", + workflow::format_findings(&run.deferred_findings) + )); + } + summary.push_str("\n\nReview the changes and commit when ready."); + } + WorkflowOutcome::Stopped => { + run.state = RunState::Failed; + summary.push_str(&format!("Workflow **{}** stopped.", run.spec.name)); + } + WorkflowOutcome::Error(reason) => { + run.state = RunState::Failed; + summary.push_str(&format!( + "Workflow **{}** failed: {reason}. Changes made so far remain in the \ + working copy.", + run.spec.name + )); + } + } + self.workflow_timeline(parent_id, summary); + + if let Some(task) = self.tasks.get_mut(parent_id) { + match &outcome { + WorkflowOutcome::Success { .. } => task.set_status(TaskStatus::NeedsReview), + WorkflowOutcome::Stopped => task.set_status(TaskStatus::Interrupted), + WorkflowOutcome::Error(reason) => { + task.blocked_reason = Some(reason.clone()); + task.set_status(TaskStatus::Blocked); + } + } + } + self.workflow_sync(&run); + self.workflow_runs.insert(parent_id.to_string(), run); + } + + /// A stage child task was cancelled or deleted out from under the run. + async fn workflow_child_gone(&mut self, child_id: &str) { + if self.workflow_child_of(child_id).is_some() { + self.workflow_stage_finished(child_id, false, String::new()) + .await; + } + } + + // ── User-facing controls (workflow.pause / resume / reply / decide) ── + + fn workflow_pause(&mut self, parent_id: &str) -> Result<(), String> { + let Some(run) = self.workflow_runs.get_mut(parent_id) else { + return Err("no workflow pipeline on this task".to_string()); + }; + match run.state { + RunState::Running { .. } => { + if run.pause_requested { + return Err("pause already requested".to_string()); + } + run.pause_requested = true; + self.workflow_timeline( + parent_id, + "Pause requested — takes effect when the current stage finishes its turn.", + ); + let run = self.workflow_runs.get(parent_id).cloned(); + if let Some(run) = run { + self.workflow_sync(&run); + } + Ok(()) + } + RunState::Paused { .. } => Err("already paused".to_string()), + RunState::AwaitingReply { .. } | RunState::AwaitingLimitDecision => { + Err("the pipeline is already waiting for your input".to_string()) + } + RunState::Done | RunState::Failed => Err("the pipeline has finished".to_string()), + } + } + + async fn workflow_resume( + &mut self, + parent_id: &str, + note: Option, + ) -> Result<(), String> { + let next = { + let Some(run) = self.workflow_runs.get_mut(parent_id) else { + return Err("no workflow pipeline on this task".to_string()); + }; + let RunState::Paused { next } = run.state else { + return Err("the pipeline is not paused".to_string()); + }; + run.pause_requested = false; + if let Some(note) = note.filter(|n| !n.trim().is_empty()) { + self.emit_session( + parent_id, + wire::SessionUpdate::UserMessage { + text: note.clone(), + attachments: vec![], + }, + ); + let run = self.workflow_runs.get_mut(parent_id).unwrap(); + run.pending_guidance = Some(note); + } + next + }; + self.workflow_timeline(parent_id, "Resumed."); + self.workflow_spawn_stage(parent_id, next).await; + Ok(()) + } + + async fn workflow_reply(&mut self, parent_id: &str, message: String) -> Result<(), String> { + let (stage, child) = { + let Some(run) = self.workflow_runs.get(parent_id) else { + return Err("no workflow pipeline on this task".to_string()); + }; + match &run.state { + RunState::AwaitingReply { stage, child, .. } => (*stage, child.clone()), + _ => return Err("the pipeline is not waiting for an answer".to_string()), + } + }; + // Show the user's answer in the parent timeline either way. + self.emit_session( + parent_id, + wire::SessionUpdate::UserMessage { + text: message.clone(), + attachments: vec![], + }, + ); + let delivered = self + .sessions + .get(&child) + .map(|handle| { + handle + .prompt(super::prompt::PreparedPrompt { + content: vec![super::prompt::PromptContent::Text(message.clone())], + summaries: vec![], + has_images: false, + }) + .is_ok() + }) + .unwrap_or(false); + if delivered { + self.emit_session( + &child, + wire::SessionUpdate::UserMessage { + text: message, + attachments: vec![], + }, + ); + self.mark_task_running(&child); + if let Some(run) = self.workflow_runs.get_mut(parent_id) { + run.state = RunState::Running { stage }; + } + self.workflow_timeline( + parent_id, + format!("Answer delivered — stage **{}** continues.", stage.label()), + ); + let run = self.workflow_runs.get(parent_id).cloned(); + if let Some(run) = run { + self.workflow_sync(&run); + } + } else { + // The asking session is gone (daemon restarted, agent died). Re-run + // the stage with the question + answer as guidance instead. + let question = { + let run = self.workflow_runs.get_mut(parent_id).unwrap(); + let question = match &run.state { + RunState::AwaitingReply { question, .. } => question.clone(), + _ => String::new(), + }; + run.active_children.remove(&child); + run.set_record_status(&child, wire::OrchNodeStatus::Skipped); + run.pending_guidance = Some(format!( + "The previous attempt of this stage asked:\n> {question}\n\nUser's answer:\n{message}" + )); + question + }; + let _ = question; + self.workflow_timeline( + parent_id, + format!( + "The asking session is no longer alive — re-running stage **{}** with your \ + answer as guidance.", + stage.label() + ), + ); + self.workflow_spawn_stage(parent_id, stage).await; + } + Ok(()) + } + + async fn workflow_decide( + &mut self, + parent_id: &str, + decision: wire::WorkflowDecision, + rounds: Option, + note: Option, + ) -> Result<(), String> { + { + let Some(run) = self.workflow_runs.get(parent_id) else { + return Err("no workflow pipeline on this task".to_string()); + }; + if run.state != RunState::AwaitingLimitDecision { + return Err("the pipeline is not waiting for a limit decision".to_string()); + } + } + match decision { + wire::WorkflowDecision::Extend => { + let granted = rounds.unwrap_or(1).clamp(1, workflow::MAX_EXTEND_ROUNDS); + { + let run = self.workflow_runs.get_mut(parent_id).unwrap(); + run.extra_rounds += granted; + run.pending_guidance = note.filter(|n| !n.trim().is_empty()); + } + self.workflow_timeline( + parent_id, + format!("You granted {granted} more review round(s) — continuing with a fix."), + ); + self.workflow_advance(parent_id, StageKind::Fix).await; + Ok(()) + } + wire::WorkflowDecision::Finish => { + self.workflow_timeline(parent_id, "You chose to finish with the open findings."); + self.workflow_finalize(parent_id, WorkflowOutcome::Success { limit_hit: true }) + .await; + Ok(()) + } + wire::WorkflowDecision::Stop => { + self.workflow_finalize(parent_id, WorkflowOutcome::Stopped) + .await; + Ok(()) + } + } + } + + /// Restore persisted runs after a daemon restart. Barrier states survive + /// as-is; a run caught mid-stage converts to `Paused` at its last barrier + /// (resume re-runs the interrupted stage from scratch). + fn restore_workflow_runs(&mut self) { + let rows = self + .store + .as_ref() + .and_then(|s| s.load_workflow_runs().ok()) + .unwrap_or_default(); + for (task_id, json) in rows { + let Ok(mut run) = serde_json::from_str::(&json) else { + eprintln!("[daemon] dropping unreadable workflow run for task {task_id}"); + continue; + }; + if !self.tasks.contains_key(&task_id) { + continue; + } + if run.is_active() { + if let RunState::Running { stage } = run.state { + // Sessions died with the previous daemon: park at the + // barrier before the interrupted stage. + let children: Vec = run.active_children.keys().cloned().collect(); + for child in &children { + run.set_record_status(child, wire::OrchNodeStatus::Failed); + } + run.active_children.clear(); + run.review_pending.clear(); + run.review_collected.clear(); + run.state = RunState::Paused { next: stage }; + self.workflow_timeline( + &task_id, + format!( + "Daemon restarted while stage **{}** was running. The pipeline is \ + paused — resume to re-run that stage.", + stage.label() + ), + ); + } + // The store normalizes Running → Interrupted on load; a live + // pipeline parent is Running again (its waiting state, if any, + // is carried by workflow_run). + if let Some(task) = self.tasks.get_mut(&task_id) { + task.blocked_reason = None; + task.set_status(TaskStatus::Running); + } + } + if let Some(task) = self.tasks.get_mut(&task_id) { + task.workflow_run = Some(run.wire_info()); + task.orchestration_graph = Some(run.graph_info()); + let updated = task.clone(); + self.persist(&updated); + } + if let Some(store) = &self.store { + if let Ok(json) = serde_json::to_string(&run) { + let _ = store.save_workflow_run(&task_id, &json); + } + } + self.workflow_runs.insert(task_id, run); + } + } +} + /// Create the default policy set for a new daemon. fn default_policies() -> PolicyRegistry { let mut reg = PolicyRegistry::new(); diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index d56ea20..b9f6713 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -20,6 +20,7 @@ pub mod sessions; pub mod store; pub mod task; pub mod wire; +pub mod workflow; pub mod worktree; #[allow(unused_imports)] @@ -573,6 +574,346 @@ mod tests { ); } + // ── Workflow pipeline engine ── + + const WF_FIXTURE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/mock-acp-workflow.mjs" + ); + + /// A tempdir project with one workflow file at + /// `.warpforge/workflows/test.yaml` and a registered `demo` project entry. + fn workflow_project(yaml: &str) -> (tempfile::TempDir, Vec) { + let dir = tempfile::tempdir().unwrap(); + let wf_dir = dir.path().join(".warpforge/workflows"); + std::fs::create_dir_all(&wf_dir).unwrap(); + std::fs::write(wf_dir.join("test.yaml"), yaml).unwrap(); + let projects = vec![ProjectEntry { + name: "demo".into(), + path: dir.path().to_string_lossy().into_owned(), + added_at: "0".into(), + }]; + (dir, projects) + } + + /// Scripted mock-agent command sharing `state` across stage processes. + fn wf_agent(dir: &tempfile::TempDir, state: &str, script: &str) -> String { + format!( + "node {WF_FIXTURE} {} {script}", + dir.path().join(state).display() + ) + } + + async fn create_workflow_task(daemon: &DaemonHandle, agent: &str) -> String { + let (tx, rx) = tokio::sync::oneshot::channel(); + daemon + .send(Command::CreateWorkflowTask { + project: "demo".into(), + prompt: "do the thing".into(), + agent: agent.into(), + tags: vec![], + worktree: false, + workflow: "test".into(), + attachments: vec![], + default_model: None, + reply: tx, + }) + .await; + rx.await.unwrap().expect("workflow task created") + } + + /// Drive the event stream until the parent task satisfies `pred`. + async fn wait_for_parent( + events: &mut tokio::sync::broadcast::Receiver, + parent_id: &str, + what: &str, + pred: impl Fn(&Task) -> bool, + ) -> Task { + timeout(Duration::from_secs(20), async { + loop { + if let Ok(Event::TaskUpdated(task)) = events.recv().await { + if task.id == parent_id && pred(&task) { + break task; + } + } + } + }) + .await + .unwrap_or_else(|_| panic!("timed out waiting for: {what}")) + } + + #[tokio::test] + async fn workflow_full_loop_reject_fix_approve() { + use warpforge_protocol as wire; + let (dir, projects) = workflow_project("name: placeholder\n"); + // Round 1 rejects with one high finding, round 2 approves. + let reviewer = wf_agent(&dir, "rev.state", "reject approve"); + std::fs::write( + dir.path().join(".warpforge/workflows/test.yaml"), + format!( + "name: Test flow\nreview:\n max_rounds: 2\n reviewers:\n - agent: {reviewer}\n" + ), + ) + .unwrap(); + let lead = wf_agent(&dir, "impl.state", "impl fix"); + + let store = Store::open_at(std::path::Path::new(":memory:")).ok(); + let daemon = Daemon::spawn(projects, store); + let mut events = daemon.subscribe(); + let parent_id = create_workflow_task(&daemon, &lead).await; + + let done = wait_for_parent(&mut events, &parent_id, "pipeline done", |t| { + t.workflow_run + .as_ref() + .is_some_and(|w| w.stage == wire::WorkflowStage::Done) + }) + .await; + + assert_eq!(done.status, TaskStatus::NeedsReview); + let run = done.workflow_run.unwrap(); + assert_eq!(run.round, 2, "reject → fix → approve takes two rounds"); + assert_eq!(run.verdict, Some(wire::WorkflowVerdict::Approve)); + assert!(run.waiting.is_none()); + // Graph: implement, review r1, fix, review r2 — all with task ids. + let graph = done.orchestration_graph.unwrap(); + assert_eq!(graph.nodes.len(), 4, "{:?}", graph.nodes); + assert!(graph.nodes.iter().all(|n| n.task_id.is_some())); + assert_eq!(graph.nodes[0].kind, wire::OrchNodeKind::Implement); + assert_eq!(graph.nodes[2].kind, wire::OrchNodeKind::Fix); + } + + #[tokio::test] + async fn workflow_plan_question_reply_flow() { + use warpforge_protocol as wire; + let (dir, projects) = workflow_project("name: placeholder\n"); + let reviewer = wf_agent(&dir, "rev.state", "approve"); + std::fs::write( + dir.path().join(".warpforge/workflows/test.yaml"), + format!("name: Q flow\nplan: {{}}\nreview:\n reviewers:\n - agent: {reviewer}\n"), + ) + .unwrap(); + // Plan turn 1 asks, the answered turn plans, then implement runs. + let lead = wf_agent(&dir, "lead.state", "question plan impl"); + + let store = Store::open_at(std::path::Path::new(":memory:")).ok(); + let daemon = Daemon::spawn(projects, store); + let mut events = daemon.subscribe(); + let parent_id = create_workflow_task(&daemon, &lead).await; + + let waiting = wait_for_parent(&mut events, &parent_id, "question", |t| { + t.workflow_run + .as_ref() + .and_then(|w| w.waiting.as_ref()) + .is_some_and(|w| w.kind == wire::WorkflowWaitKind::Question) + }) + .await; + let question = waiting.workflow_run.unwrap().waiting.unwrap(); + assert_eq!(question.question.as_deref(), Some("Which database?")); + assert_eq!(question.stage, Some(wire::WorkflowStage::Plan)); + + let (tx, rx) = tokio::sync::oneshot::channel(); + daemon + .send(Command::WorkflowReply { + task: parent_id.clone(), + message: "Postgres".into(), + reply: tx, + }) + .await; + rx.await.unwrap().expect("reply accepted"); + + let done = wait_for_parent(&mut events, &parent_id, "pipeline done", |t| { + t.workflow_run + .as_ref() + .is_some_and(|w| w.stage == wire::WorkflowStage::Done) + }) + .await; + assert_eq!(done.status, TaskStatus::NeedsReview); + assert_eq!(done.workflow_run.unwrap().round, 1); + } + + #[tokio::test] + async fn workflow_limit_asks_and_finishes_on_decision() { + use warpforge_protocol as wire; + let (dir, projects) = workflow_project("name: placeholder\n"); + let reviewer = wf_agent(&dir, "rev.state", "reject"); + std::fs::write( + dir.path().join(".warpforge/workflows/test.yaml"), + format!( + "name: Limit flow\nreview:\n max_rounds: 1\n on_limit: ask\n reviewers:\n - agent: {reviewer}\n" + ), + ) + .unwrap(); + let lead = wf_agent(&dir, "impl.state", "impl fix"); + + let store = Store::open_at(std::path::Path::new(":memory:")).ok(); + let daemon = Daemon::spawn(projects, store); + let mut events = daemon.subscribe(); + let parent_id = create_workflow_task(&daemon, &lead).await; + + wait_for_parent(&mut events, &parent_id, "limit decision", |t| { + t.workflow_run + .as_ref() + .and_then(|w| w.waiting.as_ref()) + .is_some_and(|w| w.kind == wire::WorkflowWaitKind::Limit) + }) + .await; + + // A pause is invalid while waiting on a decision. + let (tx, rx) = tokio::sync::oneshot::channel(); + daemon + .send(Command::WorkflowPause { + task: parent_id.clone(), + reply: tx, + }) + .await; + assert!(rx.await.unwrap().is_err()); + + let (tx, rx) = tokio::sync::oneshot::channel(); + daemon + .send(Command::WorkflowDecide { + task: parent_id.clone(), + decision: wire::WorkflowDecision::Finish, + rounds: None, + note: None, + reply: tx, + }) + .await; + rx.await.unwrap().expect("decision accepted"); + + let done = wait_for_parent(&mut events, &parent_id, "pipeline done", |t| { + t.workflow_run + .as_ref() + .is_some_and(|w| w.stage == wire::WorkflowStage::Done) + }) + .await; + assert_eq!(done.status, TaskStatus::NeedsReview); + assert_eq!( + done.workflow_run.unwrap().verdict, + Some(wire::WorkflowVerdict::RequestChanges) + ); + } + + #[tokio::test] + async fn workflow_pause_takes_effect_at_barrier_and_resumes() { + use warpforge_protocol as wire; + let (dir, projects) = workflow_project("name: placeholder\n"); + let reviewer = wf_agent(&dir, "rev.state", "approve"); + std::fs::write( + dir.path().join(".warpforge/workflows/test.yaml"), + format!("name: Pause flow\nreview:\n reviewers:\n - agent: {reviewer}\n"), + ) + .unwrap(); + // The implement turn takes ~600ms — enough for the pause to land. + let lead = wf_agent(&dir, "impl.state", "slow-impl fix"); + + let store = Store::open_at(std::path::Path::new(":memory:")).ok(); + let daemon = Daemon::spawn(projects, store); + let mut events = daemon.subscribe(); + let parent_id = create_workflow_task(&daemon, &lead).await; + + let (tx, rx) = tokio::sync::oneshot::channel(); + daemon + .send(Command::WorkflowPause { + task: parent_id.clone(), + reply: tx, + }) + .await; + rx.await.unwrap().expect("pause accepted while running"); + + wait_for_parent(&mut events, &parent_id, "paused at barrier", |t| { + t.workflow_run + .as_ref() + .and_then(|w| w.waiting.as_ref()) + .is_some_and(|w| w.kind == wire::WorkflowWaitKind::Paused) + }) + .await; + + let (tx, rx) = tokio::sync::oneshot::channel(); + daemon + .send(Command::WorkflowResume { + task: parent_id.clone(), + note: Some("carry on".into()), + reply: tx, + }) + .await; + rx.await.unwrap().expect("resume accepted"); + + let done = wait_for_parent(&mut events, &parent_id, "pipeline done", |t| { + t.workflow_run + .as_ref() + .is_some_and(|w| w.stage == wire::WorkflowStage::Done) + }) + .await; + assert_eq!(done.status, TaskStatus::NeedsReview); + } + + /// A daemon restart mid-stage parks the pipeline at its last barrier as + /// Paused; resume re-runs the interrupted stage and the run completes. + #[tokio::test] + async fn workflow_restart_converts_midstage_to_paused_and_resumes() { + use warpforge_protocol as wire; + let (dir, projects) = workflow_project("name: placeholder\n"); + let reviewer = wf_agent(&dir, "rev.state", "approve"); + std::fs::write( + dir.path().join(".warpforge/workflows/test.yaml"), + format!("name: Restart flow\nreview:\n reviewers:\n - agent: {reviewer}\n"), + ) + .unwrap(); + // Attempt 1 of implement is slow and dies with the daemon; the re-run + // after restart pops the next behavior and completes quickly. + let lead = wf_agent(&dir, "impl.state", "slow-impl impl fix"); + let db_path = dir.path().join("warpforge.db"); + + let daemon = Daemon::spawn(projects.clone(), Store::open_at(&db_path).ok()); + let parent_id = create_workflow_task(&daemon, &lead).await; + // Shut down while the implement turn is still in flight (~600ms). + daemon.shutdown().await; + + let daemon = Daemon::spawn(projects, Store::open_at(&db_path).ok()); + let mut events = daemon.subscribe(); + let restored = timeout(Duration::from_secs(5), async { + loop { + let tasks = daemon.tasks().await; + if let Some(task) = tasks.iter().find(|t| t.id == parent_id) { + if task + .workflow_run + .as_ref() + .and_then(|w| w.waiting.as_ref()) + .is_some_and(|w| w.kind == wire::WorkflowWaitKind::Paused) + { + break task.clone(); + } + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .expect("restored run should be paused at the implement barrier"); + assert_eq!(restored.status, TaskStatus::Running); + assert_eq!( + restored.workflow_run.unwrap().stage, + wire::WorkflowStage::Implement + ); + + let (tx, rx) = tokio::sync::oneshot::channel(); + daemon + .send(Command::WorkflowResume { + task: parent_id.clone(), + note: None, + reply: tx, + }) + .await; + rx.await.unwrap().expect("resume accepted after restart"); + + let done = wait_for_parent(&mut events, &parent_id, "pipeline done", |t| { + t.workflow_run + .as_ref() + .is_some_and(|w| w.stage == wire::WorkflowStage::Done) + }) + .await; + assert_eq!(done.status, TaskStatus::NeedsReview); + } + /// Regression: when a stale ACP handle is in sessions and a prompt /// arrives, the daemon must detect the dead handle and trigger resume /// via the stored session_id rather than failing with "no live session". diff --git a/src/daemon/server.rs b/src/daemon/server.rs index 3f7e6f9..6d59b5b 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -418,7 +418,32 @@ async fn dispatch( attachments, default_model, config_overrides, + workflow, } => { + if let Some(workflow) = workflow { + let (tx, rx) = oneshot::channel(); + handle + .send(Command::CreateWorkflowTask { + project, + prompt, + agent, + tags, + worktree, + workflow, + attachments, + default_model, + reply: tx, + }) + .await; + let id = rx + .await + .unwrap_or_else(|_| Err("daemon closed".into())) + .map_err(|e| wire::RpcError { + code: wire::ErrorCode::InvalidRequest, + message: e, + })?; + return Ok(json!({ "taskId": id })); + } let id = handle .create_task( &project, @@ -850,6 +875,40 @@ async fn dispatch( })?; Ok(json!({ "path": target.to_string_lossy() })) } + WorkflowPause { task } => { + workflow_control(handle, |reply| Command::WorkflowPause { task, reply }).await + } + WorkflowResume { task, note } => { + workflow_control(handle, |reply| Command::WorkflowResume { + task, + note, + reply, + }) + .await + } + WorkflowReply { task, message } => { + workflow_control(handle, |reply| Command::WorkflowReply { + task, + message, + reply, + }) + .await + } + WorkflowDecide { + task, + decision, + rounds, + note, + } => { + workflow_control(handle, |reply| Command::WorkflowDecide { + task, + decision, + rounds, + note, + reply, + }) + .await + } ProjectAdd { path, name } => { let entry = handle .add_project(&path, name.as_deref()) @@ -951,6 +1010,24 @@ fn validate_issues(yaml: &str) -> Vec { } } +/// Send a workflow control command and map its `Result<(), String>` reply to +/// an RPC response (`null` on success, `InvalidRequest` with the reason +/// otherwise — e.g. the pipeline is not in the state the control expects). +async fn workflow_control( + handle: &DaemonHandle, + build: impl FnOnce(oneshot::Sender>) -> Command, +) -> Result { + let (tx, rx) = oneshot::channel(); + handle.send(build(tx)).await; + rx.await + .unwrap_or_else(|_| Err("daemon closed".into())) + .map_err(|e| wire::RpcError { + code: wire::ErrorCode::InvalidRequest, + message: e, + })?; + Ok(json!(null)) +} + /// Wire form of one workflow definition for the New Task picker. fn workflow_meta(w: crate::workflow_config::LoadedWorkflow) -> wire::WorkflowMeta { let source = match w.source { diff --git a/src/daemon/store.rs b/src/daemon/store.rs index 5be0b9a..695b4c0 100644 --- a/src/daemon/store.rs +++ b/src/daemon/store.rs @@ -194,6 +194,11 @@ impl Store { id INTEGER PRIMARY KEY CHECK (id = 1), config_json TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS workflow_runs ( + task_id TEXT PRIMARY KEY, + run_json TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); "#, )?; // Existing databases from before config selector persistence won't have @@ -312,6 +317,7 @@ impl Store { config_options: serde_json::from_str(&config_options_json).unwrap_or_default(), worktree: row.get(12)?, orchestration_graph: None, + workflow_run: None, parent_task_id: row.get(13)?, settled_override: row.get::<_, Option>(15)?.map(|v| v != 0), settled_at: row.get::<_, Option>(16)?, @@ -414,12 +420,44 @@ impl Store { Ok(()) } + /// Persist a workflow pipeline's full state (spec snapshot included) as + /// one JSON blob, replacing any previous snapshot for the task. + pub fn save_workflow_run(&self, task_id: &str, run_json: &str) -> Result<()> { + self.conn.execute( + "INSERT OR REPLACE INTO workflow_runs (task_id, run_json, updated_at) \ + VALUES (?1, ?2, strftime('%s','now'))", + rusqlite::params![task_id, run_json], + )?; + Ok(()) + } + + /// All persisted workflow runs as (task_id, run_json) pairs. + pub fn load_workflow_runs(&self) -> Result> { + let mut stmt = self + .conn + .prepare("SELECT task_id, run_json FROM workflow_runs")?; + let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?; + Ok(rows.filter_map(|r| r.ok()).collect()) + } + + pub fn delete_workflow_run(&self, task_id: &str) -> Result<()> { + self.conn.execute( + "DELETE FROM workflow_runs WHERE task_id = ?1", + rusqlite::params![task_id], + )?; + Ok(()) + } + /// Delete a task and its session history permanently. pub fn delete_task(&self, id: &str) -> Result<()> { self.conn.execute( "DELETE FROM session_updates WHERE task_id = ?1", rusqlite::params![id], )?; + self.conn.execute( + "DELETE FROM workflow_runs WHERE task_id = ?1", + rusqlite::params![id], + )?; self.conn .execute("DELETE FROM tasks WHERE id = ?1", rusqlite::params![id])?; Ok(()) diff --git a/src/daemon/task.rs b/src/daemon/task.rs index 808b4b9..198d97c 100644 --- a/src/daemon/task.rs +++ b/src/daemon/task.rs @@ -64,6 +64,9 @@ pub struct Task { pub worktree: Option, /// Orchestration graph for parent orchestrator tasks. pub orchestration_graph: Option, + /// Live workflow pipeline state for workflow parent tasks. Derived from + /// the engine's run (not persisted with the task — the run itself is). + pub workflow_run: Option, /// When this task was spawned by an orchestrator agent as a sub-agent, the /// id of that orchestrator task. Its result is delivered back into the /// parent's inbox on completion. @@ -99,6 +102,7 @@ impl Task { config_options: Vec::new(), worktree: None, orchestration_graph: None, + workflow_run: None, parent_task_id: None, settled_override: None, settled_at: None, diff --git a/src/daemon/wire.rs b/src/daemon/wire.rs index 63d7cf0..180c66a 100644 --- a/src/daemon/wire.rs +++ b/src/daemon/wire.rs @@ -136,6 +136,7 @@ pub fn task_info(t: &Task) -> wire::TaskInfo { worktree: t.worktree.clone(), orchestration_graph: t.orchestration_graph.clone(), parent_task_id: t.parent_task_id.clone(), + workflow_run: t.workflow_run.clone(), settled_override: t.settled_override, settled_at: t.settled_at, snoozed_until: t.snoozed_until, diff --git a/src/daemon/workflow.rs b/src/daemon/workflow.rs new file mode 100644 index 0000000..90c1c43 --- /dev/null +++ b/src/daemon/workflow.rs @@ -0,0 +1,1220 @@ +//! Deterministic workflow pipeline engine: run-state container and pure +//! helpers (stage prompts, verdict/marker parsing, review merging, context +//! formatting). +//! +//! The pipeline shape is fixed: `plan? → implement → review ⇄ fix` (see +//! design doc). This module has no side effects — the actor +//! glue that spawns stage sessions, reacts to turn ends, and emits events +//! lives in `actor.rs` and calls into these helpers, so everything here is +//! unit-testable in isolation. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use warpforge_protocol as wire; + +use crate::workflow_config::{render_template, ReviewContextItem, WorkflowSpec}; + +/// Byte budget for the diff embedded into review/fix prompts. +pub const DIFF_CONTEXT_MAX_BYTES: usize = 200 * 1024; +/// Byte budget for the implementer-summary context section (tail wins). +pub const SUMMARY_CONTEXT_MAX_BYTES: usize = 16 * 1024; +/// How many times a reviewer is re-asked for a parseable verdict before the +/// pipeline fails. +pub const MAX_VERDICT_REASKS: u8 = 1; +/// Cap on rounds granted by a single `workflow.decide { extend }`. +pub const MAX_EXTEND_ROUNDS: u32 = 5; + +// ─── Stages and state ──────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StageKind { + Plan, + Implement, + Review, + Fix, +} + +impl StageKind { + pub fn label(self) -> &'static str { + match self { + StageKind::Plan => "plan", + StageKind::Implement => "implement", + StageKind::Review => "review", + StageKind::Fix => "fix", + } + } + + pub fn wire(self) -> wire::WorkflowStage { + match self { + StageKind::Plan => wire::WorkflowStage::Plan, + StageKind::Implement => wire::WorkflowStage::Implement, + StageKind::Review => wire::WorkflowStage::Review, + StageKind::Fix => wire::WorkflowStage::Fix, + } + } + + pub fn node_kind(self) -> wire::OrchNodeKind { + match self { + StageKind::Plan => wire::OrchNodeKind::Plan, + StageKind::Implement => wire::OrchNodeKind::Implement, + StageKind::Review => wire::OrchNodeKind::Review, + StageKind::Fix => wire::OrchNodeKind::Fix, + } + } + + /// The stage that follows a successfully completed one. Review is not a + /// simple successor — it branches on the merged verdict — so it has no + /// entry here. + pub fn successor(self) -> Option { + match self { + StageKind::Plan => Some(StageKind::Implement), + StageKind::Implement => Some(StageKind::Review), + StageKind::Fix => Some(StageKind::Review), + StageKind::Review => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "state")] +pub enum RunState { + /// A stage's child session(s) are running. + Running { + stage: StageKind, + }, + /// A stage asked `need_user_input`; suspended until `workflow.reply`. + AwaitingReply { + stage: StageKind, + child: String, + question: String, + }, + /// Review rounds exhausted with open findings; suspended until + /// `workflow.decide`. + AwaitingLimitDecision, + /// Soft-paused at a stage barrier; `next` starts on `workflow.resume`. + Paused { + next: StageKind, + }, + Done, + Failed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Severity { + Critical, + High, + Medium, + Low, +} + +impl Severity { + pub fn label(self) -> &'static str { + match self { + Severity::Critical => "critical", + Severity::High => "high", + Severity::Medium => "medium", + Severity::Low => "low", + } + } + + /// Low-severity findings go to the final summary, not to the fixer. + pub fn goes_to_fix(self) -> bool { + !matches!(self, Severity::Low) + } + + fn parse(s: &str) -> Severity { + match s.to_ascii_lowercase().as_str() { + "critical" | "blocker" => Severity::Critical, + "high" | "major" => Severity::High, + "low" | "minor" | "nit" | "info" => Severity::Low, + _ => Severity::Medium, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Finding { + pub severity: Severity, + pub file: Option, + pub description: String, + /// Reviewer label, e.g. "reviewer 2 (codex)". + pub reviewer: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Verdict { + Approve, + RequestChanges, +} + +impl Verdict { + pub fn wire(self) -> wire::WorkflowVerdict { + match self { + Verdict::Approve => wire::WorkflowVerdict::Approve, + Verdict::RequestChanges => wire::WorkflowVerdict::RequestChanges, + } + } +} + +/// How a pipeline ends. +#[derive(Debug, Clone, PartialEq)] +pub enum WorkflowOutcome { + /// Reviewers approved, or the user chose to finish with open findings. + Success { limit_hit: bool }, + /// Stopped by the user (task cancel, or `workflow.decide { stop }`). + Stopped, + /// Infrastructure or protocol failure. + Error(String), +} + +/// One spawned stage child, kept for the orchestration graph on the board. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StageRecord { + pub kind: StageKind, + pub task_id: String, + pub agent: String, + /// Display label, e.g. "review 1/2 (codex)". + pub label: String, + pub status: wire::OrchNodeStatus, +} + +// ─── The run ───────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkflowRun { + pub parent_id: String, + pub project: String, + pub spec: WorkflowSpec, + /// Lead agent / model picked in the New Task dialog — the fallback for + /// every stage that doesn't override them. + pub lead_agent: String, + pub lead_model: Option, + pub state: RunState, + /// 1-based review round; 0 until the first review starts. + pub round: u32, + /// Extra rounds granted by `workflow.decide { extend }`. + pub extra_rounds: u32, + /// Set by `workflow.pause` while a stage is running; takes effect at the + /// next stage barrier. + pub pause_requested: bool, + /// Free-text from resume/decide, delivered to the next spawned stage as a + /// "User guidance" block and then cleared. + pub pending_guidance: Option, + pub plan_output: Option, + /// Final text of the last implement/fix session. + pub last_summary: Option, + pub last_verdict: Option, + /// Findings of the latest review round that still need fixing. + pub open_findings: Vec, + /// Low-severity findings accumulated for the final summary only. + pub deferred_findings: Vec, + /// child task id → reviewer index, while a review stage is in flight. + pub review_pending: HashMap, + /// (reviewer index, verdict, findings) collected this round. + pub review_collected: Vec<(usize, Verdict, Vec)>, + /// child task id → verdict re-ask count. + pub reasked: HashMap, + /// child task id → stage kind, for routing TurnEnded (single-child stages). + pub active_children: HashMap, + pub history: Vec, + /// Attachments from the New Task dialog, delivered to the first stage. + pub attachments: Vec, +} + +impl WorkflowRun { + #[allow(clippy::too_many_arguments)] + pub fn new( + parent_id: String, + project: String, + spec: WorkflowSpec, + lead_agent: String, + lead_model: Option, + attachments: Vec, + ) -> Self { + Self { + parent_id, + project, + spec, + lead_agent, + lead_model, + state: RunState::Running { + stage: StageKind::Implement, // set properly by the first spawn + }, + round: 0, + extra_rounds: 0, + pause_requested: false, + pending_guidance: None, + plan_output: None, + last_summary: None, + last_verdict: None, + open_findings: Vec::new(), + deferred_findings: Vec::new(), + review_pending: HashMap::new(), + review_collected: Vec::new(), + reasked: HashMap::new(), + active_children: HashMap::new(), + history: Vec::new(), + attachments: Vec::new(), + } + .with_attachments(attachments) + } + + fn with_attachments(mut self, attachments: Vec) -> Self { + self.attachments = attachments; + self + } + + pub fn first_stage(&self) -> StageKind { + if self.spec.plan.is_some() { + StageKind::Plan + } else { + StageKind::Implement + } + } + + pub fn effective_max_rounds(&self) -> u32 { + self.spec.review.max_rounds + self.extra_rounds + } + + pub fn is_active(&self) -> bool { + !matches!(self.state, RunState::Done | RunState::Failed) + } + + /// The pipeline is at a stage barrier: no agent turn is in flight, so the + /// state survives a daemon restart as-is. + pub fn at_barrier(&self) -> bool { + matches!( + self.state, + RunState::AwaitingReply { .. } + | RunState::AwaitingLimitDecision + | RunState::Paused { .. } + | RunState::Done + | RunState::Failed + ) + } + + /// Agent + model for a stage, applying the fallback chain: + /// stage override → (fix falls back to implement) → lead agent/model. + pub fn stage_agent( + &self, + kind: StageKind, + reviewer: Option, + ) -> (String, Option) { + let (agent, model) = match kind { + StageKind::Plan => { + let s = self.spec.plan.as_ref(); + ( + s.and_then(|s| s.agent.clone()), + s.and_then(|s| s.model.clone()), + ) + } + StageKind::Implement => ( + self.spec.implement.agent.clone(), + self.spec.implement.model.clone(), + ), + StageKind::Fix => ( + self.spec + .fix + .agent + .clone() + .or_else(|| self.spec.implement.agent.clone()), + self.spec + .fix + .model + .clone() + .or_else(|| self.spec.implement.model.clone()), + ), + StageKind::Review => { + let r = reviewer.and_then(|i| self.spec.review.reviewers.get(i)); + ( + r.and_then(|r| r.agent.clone()), + r.and_then(|r| r.model.clone()), + ) + } + }; + ( + agent.unwrap_or_else(|| self.lead_agent.clone()), + model.or_else(|| self.lead_model.clone()), + ) + } + + pub fn reviewer_label(&self, index: usize) -> String { + let total = self.spec.review.reviewers.len(); + let (agent, _) = self.stage_agent(StageKind::Review, Some(index)); + if total == 1 { + format!("reviewer ({agent})") + } else { + format!("reviewer {}/{total} ({agent})", index + 1) + } + } + + pub fn record_stage(&mut self, kind: StageKind, task_id: &str, agent: &str, label: String) { + self.history.push(StageRecord { + kind, + task_id: task_id.to_string(), + agent: agent.to_string(), + label, + status: wire::OrchNodeStatus::Running, + }); + } + + pub fn set_record_status(&mut self, task_id: &str, status: wire::OrchNodeStatus) { + if let Some(rec) = self.history.iter_mut().rev().find(|r| r.task_id == task_id) { + rec.status = status; + } + } + + /// Take the pending guidance (it is delivered to exactly one stage). + pub fn take_guidance(&mut self) -> Option { + self.pending_guidance.take() + } + + // ── Wire projections ── + + pub fn wire_info(&self) -> wire::WorkflowRunInfo { + let (stage, waiting) = match &self.state { + RunState::Running { stage } => (stage.wire(), None), + RunState::AwaitingReply { + stage, question, .. + } => ( + stage.wire(), + Some(wire::WorkflowWaiting { + kind: wire::WorkflowWaitKind::Question, + stage: Some(stage.wire()), + question: Some(question.clone()), + }), + ), + RunState::AwaitingLimitDecision => ( + wire::WorkflowStage::Review, + Some(wire::WorkflowWaiting { + kind: wire::WorkflowWaitKind::Limit, + stage: Some(wire::WorkflowStage::Review), + question: Some(summarize_findings(&self.open_findings)), + }), + ), + RunState::Paused { next } => ( + next.wire(), + Some(wire::WorkflowWaiting { + kind: wire::WorkflowWaitKind::Paused, + stage: Some(next.wire()), + question: None, + }), + ), + RunState::Done => (wire::WorkflowStage::Done, None), + RunState::Failed => (wire::WorkflowStage::Failed, None), + }; + wire::WorkflowRunInfo { + workflow_id: self.spec.id.clone(), + workflow_name: self.spec.name.clone(), + stage, + round: self.round, + max_rounds: self.effective_max_rounds(), + verdict: self.last_verdict.map(Verdict::wire), + waiting, + } + } + + pub fn graph_info(&self) -> wire::OrchGraphInfo { + wire::OrchGraphInfo { + id: self.parent_id.clone(), + goal: self.spec.name.clone(), + nodes: self + .history + .iter() + .map(|rec| wire::OrchNodeInfo { + id: rec.label.clone(), + kind: rec.kind.node_kind(), + agent: rec.agent.clone(), + status: rec.status, + task_id: Some(rec.task_id.clone()), + result: None, + }) + .collect(), + } + } +} + +// ─── Output parsing ────────────────────────────────────────────────────────── + +/// The machine-readable signal at the end of a plan/implement/fix stage. +#[derive(Debug, Clone, PartialEq)] +pub enum StageSignal { + /// The stage needs an answer from the user before it can continue. + Question(String), + /// Normal completion; the stage's text output is its result. + Output, +} + +/// Scan a stage's output for the trailing `need_user_input` marker. +pub fn parse_stage_signal(text: &str) -> StageSignal { + if let Some(value) = extract_last_json_object(text) { + if let Some(q) = value.get("need_user_input").and_then(|v| v.as_str()) { + let q = q.trim(); + if !q.is_empty() { + return StageSignal::Question(q.to_string()); + } + } + } + StageSignal::Output +} + +/// Parse a reviewer's verdict from its output. `Err` is a human-readable +/// reason suitable for the re-ask prompt / failure message. +pub fn parse_review_verdict(text: &str, reviewer: &str) -> Result<(Verdict, Vec), String> { + let Some(value) = extract_last_json_object(text) else { + return Err("no fenced JSON verdict block found".to_string()); + }; + let verdict = match value.get("verdict").and_then(|v| v.as_str()) { + Some("approve") => Verdict::Approve, + Some("request_changes") => Verdict::RequestChanges, + Some(other) => { + return Err(format!( + "verdict must be \"approve\" or \"request_changes\", got \"{other}\"" + )) + } + None => return Err("JSON block has no \"verdict\" field".to_string()), + }; + let mut findings = Vec::new(); + if let Some(items) = value.get("findings").and_then(|v| v.as_array()) { + for item in items { + let description = item + .get("description") + .or_else(|| item.get("title")) + .or_else(|| item.get("message")) + .and_then(|v| v.as_str()) + .unwrap_or_default() + .trim() + .to_string(); + if description.is_empty() { + continue; + } + findings.push(Finding { + severity: item + .get("severity") + .and_then(|v| v.as_str()) + .map(Severity::parse) + .unwrap_or(Severity::Medium), + file: item + .get("file") + .and_then(|v| v.as_str()) + .map(str::to_string) + .filter(|f| !f.is_empty()), + description, + reviewer: reviewer.to_string(), + }); + } + } + Ok((verdict, findings)) +} + +/// The last fenced code block in `text` that parses as a JSON object. +/// Accepts both ```json and bare ``` fences — agents are inconsistent. +fn extract_last_json_object(text: &str) -> Option { + let mut best: Option = None; + let mut rest = text; + while let Some(open) = rest.find("```") { + let after_open = &rest[open + 3..]; + // Skip the info string ("json", "JSON", …) up to the first newline. + let Some(nl) = after_open.find('\n') else { + break; + }; + let body_start = nl + 1; + let Some(close) = after_open[body_start..].find("```") else { + break; + }; + let body = &after_open[body_start..body_start + close]; + if let Ok(value) = serde_json::from_str::(body.trim()) { + if value.is_object() { + best = Some(value); + } + } + rest = &after_open[body_start + close + 3..]; + } + best +} + +/// Merge one round's reviewer results: approve only when everyone approves; +/// findings are concatenated in reviewer order. +pub fn merge_reviews(collected: &[(usize, Verdict, Vec)]) -> (Verdict, Vec) { + let mut sorted: Vec<_> = collected.iter().collect(); + sorted.sort_by_key(|(idx, _, _)| *idx); + let verdict = if sorted + .iter() + .all(|(_, verdict, _)| *verdict == Verdict::Approve) + { + Verdict::Approve + } else { + Verdict::RequestChanges + }; + let findings = sorted + .iter() + .flat_map(|(_, _, findings)| findings.iter().cloned()) + .collect(); + (verdict, findings) +} + +// ─── Context formatting ────────────────────────────────────────────────────── + +/// Render a working-copy diff into prompt text, truncated to +/// [`DIFF_CONTEXT_MAX_BYTES`] with an explicit truncation note. +pub fn format_diff(files: &[wire::FileDiff]) -> String { + if files.is_empty() { + return "(no changes in the working copy)".to_string(); + } + let mut out = String::new(); + let mut truncated = false; + 'files: for file in files { + let header = match (&file.status, &file.old_path) { + (wire::FileDiffStatus::Renamed, Some(old)) => { + format!("--- {old}\n+++ {} (renamed)\n", file.path) + } + (wire::FileDiffStatus::Added, _) => format!("+++ {} (added)\n", file.path), + (wire::FileDiffStatus::Deleted, _) => format!("--- {} (deleted)\n", file.path), + _ => format!("--- a/{p}\n+++ b/{p}\n", p = file.path), + }; + out.push_str(&header); + for hunk in &file.hunks { + out.push_str(&format!( + "@@ -{},{} +{},{} @@\n", + hunk.old_start, hunk.old_lines, hunk.new_start, hunk.new_lines + )); + for line in &hunk.lines { + out.push_str(line); + out.push('\n'); + } + if out.len() > DIFF_CONTEXT_MAX_BYTES { + truncated = true; + break 'files; + } + } + out.push('\n'); + } + if truncated { + out.truncate(floor_char_boundary(&out, DIFF_CONTEXT_MAX_BYTES)); + out.push_str("\n\n[diff truncated — full list of changed files:]\n"); + for file in files { + out.push_str(&format!("- {}\n", file.path)); + } + } + out +} + +/// Keep the tail of a long implementer summary (the conclusion matters most). +pub fn clip_summary(summary: &str) -> String { + if summary.len() <= SUMMARY_CONTEXT_MAX_BYTES { + return summary.to_string(); + } + let start = ceil_char_boundary(summary, summary.len() - SUMMARY_CONTEXT_MAX_BYTES); + format!("[…truncated…]\n{}", &summary[start..]) +} + +fn floor_char_boundary(s: &str, mut i: usize) -> usize { + if i >= s.len() { + return s.len(); + } + while i > 0 && !s.is_char_boundary(i) { + i -= 1; + } + i +} + +fn ceil_char_boundary(s: &str, mut i: usize) -> usize { + while i < s.len() && !s.is_char_boundary(i) { + i += 1; + } + i +} + +pub fn format_findings(findings: &[Finding]) -> String { + findings + .iter() + .enumerate() + .map(|(i, f)| { + let file = f + .file + .as_deref() + .map(|p| format!(" `{p}`")) + .unwrap_or_default(); + format!( + "{}. [{}]{} — {} ({})", + i + 1, + f.severity.label(), + file, + f.description, + f.reviewer + ) + }) + .collect::>() + .join("\n") +} + +/// Short one-line findings summary for the limit-decision prompt. +pub fn summarize_findings(findings: &[Finding]) -> String { + let mut counts: [usize; 4] = [0; 4]; + for f in findings { + counts[match f.severity { + Severity::Critical => 0, + Severity::High => 1, + Severity::Medium => 2, + Severity::Low => 3, + }] += 1; + } + let parts: Vec = [ + (counts[0], "critical"), + (counts[1], "high"), + (counts[2], "medium"), + (counts[3], "low"), + ] + .iter() + .filter(|(n, _)| *n > 0) + .map(|(n, label)| format!("{n} {label}")) + .collect(); + if parts.is_empty() { + "no open findings".to_string() + } else { + format!("open findings: {}", parts.join(", ")) + } +} + +// ─── Prompt building ───────────────────────────────────────────────────────── + +/// Everything a stage prompt can draw on. Owned strings so the actor can +/// assemble it without borrow gymnastics. +#[derive(Debug, Default, Clone)] +pub struct PromptCtx { + pub task_prompt: String, + pub plan: Option, + pub implementer_summary: Option, + pub diff: Option, + /// Pre-formatted findings list (fix stage). + pub findings: Option, + pub round: u32, + pub max_rounds: u32, + pub guidance: Option, +} + +/// Appended to every plan/implement/fix prompt — the question protocol the +/// engine's `parse_stage_signal` understands. +const QUESTION_PROTOCOL: &str = "If you cannot proceed without an answer from the user, end your \ +reply with exactly one fenced code block of this shape and stop:\n\ +```json\n{\"need_user_input\": \"\"}\n```\n\ +Otherwise, do not emit such a block."; + +/// Appended to every reviewer prompt — the verdict protocol the engine's +/// `parse_review_verdict` understands. +const VERDICT_PROTOCOL: &str = "You MUST end your reply with exactly one fenced code block of \ +this shape:\n\ +```json\n{\"verdict\": \"approve\", \"findings\": [{\"severity\": \"high\", \"file\": \"src/example.rs\", \"description\": \"…\"}]}\n```\n\ +`verdict` is \"approve\" or \"request_changes\"; `severity` is critical, high, medium, or low; \ +`file` may be null. Use \"approve\" only when no critical, high, or medium severity problems \ +remain. Report real problems only — do not invent findings to seem thorough."; + +fn vars_from_ctx(ctx: &PromptCtx, focus: Option<&str>) -> HashMap<&'static str, String> { + let mut vars: HashMap<&'static str, String> = HashMap::new(); + vars.insert("task_prompt", ctx.task_prompt.clone()); + vars.insert("plan", ctx.plan.clone().unwrap_or_default()); + vars.insert( + "implementer_summary", + ctx.implementer_summary.clone().unwrap_or_default(), + ); + vars.insert("diff", ctx.diff.clone().unwrap_or_default()); + vars.insert("findings", ctx.findings.clone().unwrap_or_default()); + vars.insert("round", ctx.round.to_string()); + vars.insert("max_rounds", ctx.max_rounds.to_string()); + vars.insert("focus", focus.unwrap_or_default().to_string()); + vars +} + +fn push_section(out: &mut String, title: &str, body: &str) { + if body.trim().is_empty() { + return; + } + out.push_str("## "); + out.push_str(title); + out.push('\n'); + out.push_str(body.trim_end()); + out.push_str("\n\n"); +} + +fn finish_prompt(mut body: String, protocol: &str, guidance: Option<&str>) -> String { + if let Some(guidance) = guidance { + if !guidance.trim().is_empty() { + push_section(&mut body, "User guidance", guidance); + } + } + let body = body.trim_end(); + format!("{body}\n\n---\n{protocol}") +} + +pub fn build_plan_prompt(spec: &WorkflowSpec, ctx: &PromptCtx) -> String { + let body = match spec.plan.as_ref().and_then(|s| s.prompt.as_deref()) { + Some(custom) => render_template(custom, &vars_from_ctx(ctx, None)), + None => { + let mut out = String::from( + "You are the planning stage of a workflow pipeline. Explore the codebase as \ + needed and produce a concise implementation plan: the files to touch, the \ + approach, edge cases, and how to verify the result. Do NOT edit any files — \ + this stage is planning only. Your final message is handed to the implementer \ + verbatim, so end with the complete plan.\n\n", + ); + push_section(&mut out, "Task", &ctx.task_prompt); + out + } + }; + finish_prompt(body, QUESTION_PROTOCOL, ctx.guidance.as_deref()) +} + +pub fn build_implement_prompt(spec: &WorkflowSpec, ctx: &PromptCtx) -> String { + let body = match spec.implement.prompt.as_deref() { + Some(custom) => render_template(custom, &vars_from_ctx(ctx, None)), + None => { + let mut out = String::from( + "You are the implementation stage of a workflow pipeline. Implement the task \ + below completely: write the code, keep the change focused, and verify your \ + work (build/tests) where feasible. Your final message should summarize what \ + you did — it is handed to the reviewers.\n\n", + ); + push_section(&mut out, "Task", &ctx.task_prompt); + if let Some(plan) = ctx.plan.as_deref() { + push_section(&mut out, "Approved plan", plan); + } + out + } + }; + finish_prompt(body, QUESTION_PROTOCOL, ctx.guidance.as_deref()) +} + +pub fn build_reviewer_prompt(spec: &WorkflowSpec, reviewer: usize, ctx: &PromptCtx) -> String { + let config = &spec.review.reviewers[reviewer]; + let focus = config.focus.as_deref(); + let body = match config.prompt.as_deref() { + Some(custom) => render_template(custom, &vars_from_ctx(ctx, focus)), + None => { + let mut out = format!( + "You are a code reviewer in a workflow pipeline (round {}/{}). Review the \ + changes below against the task. Do NOT edit any files — review only. Judge \ + what is actually there: verify claims against the diff, and flag real \ + problems with concrete evidence.\n\n", + ctx.round, ctx.max_rounds + ); + if let Some(focus) = focus { + push_section(&mut out, "Your focus", focus); + } + for item in &spec.review.context { + match item { + ReviewContextItem::Prompt => push_section(&mut out, "Task", &ctx.task_prompt), + ReviewContextItem::Plan => { + if let Some(plan) = ctx.plan.as_deref() { + push_section(&mut out, "Approved plan", plan); + } + } + ReviewContextItem::ImplementerSummary => { + if let Some(summary) = ctx.implementer_summary.as_deref() { + push_section(&mut out, "Implementer's summary", summary); + } + } + ReviewContextItem::Diff => { + if let Some(diff) = ctx.diff.as_deref() { + push_section(&mut out, "Working-copy diff", diff); + } + } + } + } + out + } + }; + // Reviewers get no guidance block — guidance targets implement/fix. + finish_prompt(body, VERDICT_PROTOCOL, None) +} + +pub fn build_fix_prompt(spec: &WorkflowSpec, ctx: &PromptCtx) -> String { + let body = match spec.fix.prompt.as_deref() { + Some(custom) => render_template(custom, &vars_from_ctx(ctx, None)), + None => { + let mut out = format!( + "You are the repair stage of a workflow pipeline (round {}/{}). Reviewers \ + found the problems listed below. Address every finding — fix it or, when a \ + finding is factually wrong, explain why in your summary. Do not change \ + unrelated code. Your final message should summarize what you changed; it is \ + handed back to the reviewers.\n\n", + ctx.round, ctx.max_rounds + ); + push_section(&mut out, "Task", &ctx.task_prompt); + if let Some(findings) = ctx.findings.as_deref() { + push_section(&mut out, "Findings to address", findings); + } + if let Some(diff) = ctx.diff.as_deref() { + push_section(&mut out, "Current working-copy diff", diff); + } + out + } + }; + finish_prompt(body, QUESTION_PROTOCOL, ctx.guidance.as_deref()) +} + +/// Follow-up sent to a reviewer whose output had no parseable verdict. +pub fn reask_verdict_prompt(reason: &str) -> String { + format!( + "Your previous reply could not be parsed: {reason}. Reply with ONLY the fenced JSON \ + verdict block:\n```json\n{{\"verdict\": \"approve\" | \"request_changes\", \ + \"findings\": [{{\"severity\": \"…\", \"file\": \"…\", \"description\": \"…\"}}]}}\n```" + ) +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::workflow_config::parse_workflow; + + fn spec(yaml: &str) -> WorkflowSpec { + parse_workflow("test", yaml).0.expect("valid spec") + } + + fn run_for(yaml: &str) -> WorkflowRun { + WorkflowRun::new( + "t_parent".into(), + "demo".into(), + spec(yaml), + "claude".into(), + Some("lead-model".into()), + vec![], + ) + } + + #[test] + fn stage_signal_detects_trailing_question() { + let text = "I looked around.\n```json\n{\"need_user_input\": \"Which database?\"}\n```\n"; + assert_eq!( + parse_stage_signal(text), + StageSignal::Question("Which database?".into()) + ); + assert_eq!( + parse_stage_signal("all done, no questions"), + StageSignal::Output + ); + // An empty question is not a question. + assert_eq!( + parse_stage_signal("```json\n{\"need_user_input\": \" \"}\n```"), + StageSignal::Output + ); + } + + #[test] + fn verdict_parsing_happy_path_and_aliases() { + let text = r#"Review done. +```json +{"verdict": "request_changes", "findings": [ + {"severity": "HIGH", "file": "src/a.rs", "description": "off-by-one"}, + {"severity": "nit", "description": "typo"}, + {"severity": "weird", "title": "fallback title"}, + {"description": ""} +]} +```"#; + let (verdict, findings) = parse_review_verdict(text, "reviewer 1 (claude)").unwrap(); + assert_eq!(verdict, Verdict::RequestChanges); + assert_eq!(findings.len(), 3, "empty description dropped"); + assert_eq!(findings[0].severity, Severity::High); + assert_eq!(findings[0].file.as_deref(), Some("src/a.rs")); + assert_eq!(findings[1].severity, Severity::Low); + assert_eq!(findings[2].severity, Severity::Medium); + assert_eq!(findings[2].description, "fallback title"); + assert!(findings.iter().all(|f| f.reviewer == "reviewer 1 (claude)")); + } + + #[test] + fn verdict_parsing_uses_last_json_block_and_bare_fences() { + let text = "```json\n{\"verdict\": \"approve\"}\n```\nwait, actually:\n```\n{\"verdict\": \"request_changes\", \"findings\": []}\n```"; + let (verdict, _) = parse_review_verdict(text, "r").unwrap(); + assert_eq!(verdict, Verdict::RequestChanges); + } + + #[test] + fn verdict_parsing_errors() { + assert!(parse_review_verdict("no block at all", "r") + .unwrap_err() + .contains("no fenced JSON")); + assert!( + parse_review_verdict("```json\n{\"findings\": []}\n```", "r") + .unwrap_err() + .contains("no \"verdict\" field") + ); + assert!( + parse_review_verdict("```json\n{\"verdict\": \"maybe\"}\n```", "r") + .unwrap_err() + .contains("\"maybe\"") + ); + // Non-JSON fenced blocks are skipped, not fatal. + let text = "```rust\nfn x() {}\n```\n```json\n{\"verdict\": \"approve\"}\n```"; + assert!(parse_review_verdict(text, "r").is_ok()); + } + + #[test] + fn merge_reviews_requires_unanimous_approve() { + let f = |desc: &str| Finding { + severity: Severity::High, + file: None, + description: desc.into(), + reviewer: "r".into(), + }; + let (verdict, findings) = merge_reviews(&[ + (1, Verdict::Approve, vec![f("b")]), + (0, Verdict::RequestChanges, vec![f("a")]), + ]); + assert_eq!(verdict, Verdict::RequestChanges); + // Findings ordered by reviewer index. + assert_eq!(findings[0].description, "a"); + assert_eq!(findings[1].description, "b"); + + let (verdict, _) = + merge_reviews(&[(0, Verdict::Approve, vec![]), (1, Verdict::Approve, vec![])]); + assert_eq!(verdict, Verdict::Approve); + } + + #[test] + fn stage_agent_fallback_chain() { + let run = run_for( + "name: X\nplan: {}\nimplement:\n agent: codex\n model: gpt-x\nreview:\n reviewers:\n - agent: opencode\n - {}\n", + ); + // plan has no override → lead agent + lead model. + assert_eq!( + run.stage_agent(StageKind::Plan, None), + ("claude".into(), Some("lead-model".into())) + ); + // implement overrides both. + assert_eq!( + run.stage_agent(StageKind::Implement, None), + ("codex".into(), Some("gpt-x".into())) + ); + // fix falls back to implement's overrides. + assert_eq!( + run.stage_agent(StageKind::Fix, None), + ("codex".into(), Some("gpt-x".into())) + ); + // reviewer 0 overrides agent, inherits lead model; reviewer 1 → lead. + assert_eq!( + run.stage_agent(StageKind::Review, Some(0)), + ("opencode".into(), Some("lead-model".into())) + ); + assert_eq!( + run.stage_agent(StageKind::Review, Some(1)), + ("claude".into(), Some("lead-model".into())) + ); + } + + #[test] + fn first_stage_respects_plan_presence() { + assert_eq!(run_for("name: X\n").first_stage(), StageKind::Implement); + assert_eq!( + run_for("name: X\nplan: {}\n").first_stage(), + StageKind::Plan + ); + } + + #[test] + fn default_prompts_include_context_and_protocols() { + let run = run_for("name: X\nplan: {}\nreview:\n reviewers:\n - focus: security only\n"); + let ctx = PromptCtx { + task_prompt: "Add rate limiting".into(), + plan: Some("1. do things".into()), + implementer_summary: Some("did things".into()), + diff: Some("--- a/x\n+++ b/x".into()), + findings: Some("1. [high] — bug (r)".into()), + round: 1, + max_rounds: 2, + guidance: Some("prefer tower middleware".into()), + }; + + let plan = build_plan_prompt(&run.spec, &ctx); + assert!(plan.contains("Add rate limiting")); + assert!(plan.contains("need_user_input")); + assert!(plan.contains("User guidance")); + + let implement = build_implement_prompt(&run.spec, &ctx); + assert!(implement.contains("Approved plan")); + assert!(implement.contains("1. do things")); + + let review = build_reviewer_prompt(&run.spec, 0, &ctx); + assert!(review.contains("security only")); + assert!(review.contains("Working-copy diff")); + assert!(review.contains("\"verdict\"")); + assert!( + !review.contains("User guidance"), + "guidance must not leak to reviewers" + ); + + let fix = build_fix_prompt(&run.spec, &ctx); + assert!(fix.contains("Findings to address")); + assert!(fix.contains("round 1/2")); + } + + #[test] + fn custom_prompts_render_placeholders_and_keep_protocol() { + let run = run_for( + "name: X\nimplement:\n prompt: \"Do {{task_prompt}} now\"\nreview:\n reviewers:\n - prompt: \"{{focus}} check {{diff}} r{{round}}\"\n focus: perf\n", + ); + let ctx = PromptCtx { + task_prompt: "the thing".into(), + diff: Some("DIFF".into()), + round: 2, + max_rounds: 3, + ..Default::default() + }; + let implement = build_implement_prompt(&run.spec, &ctx); + assert!(implement.starts_with("Do the thing now")); + assert!( + implement.contains("need_user_input"), + "protocol always appended" + ); + + let review = build_reviewer_prompt(&run.spec, 0, &ctx); + assert!(review.starts_with("perf check DIFF r2")); + assert!(review.contains("\"verdict\"")); + } + + #[test] + fn diff_formatting_and_truncation() { + let hunk = wire::Hunk { + old_start: 1, + old_lines: 1, + new_start: 1, + new_lines: 1, + lines: vec!["-old".into(), "+new".into()], + resolution: None, + }; + let small = vec![wire::FileDiff { + path: "src/a.rs".into(), + old_path: None, + status: wire::FileDiffStatus::Modified, + hunks: vec![hunk.clone()], + }]; + let text = format_diff(&small); + assert!(text.contains("--- a/src/a.rs")); + assert!(text.contains("+new")); + + let big_hunk = wire::Hunk { + lines: vec!["+x".to_string(); 300_000], + ..hunk + }; + let big = vec![ + wire::FileDiff { + path: "src/big.rs".into(), + old_path: None, + status: wire::FileDiffStatus::Modified, + hunks: vec![big_hunk], + }, + wire::FileDiff { + path: "src/other.rs".into(), + old_path: None, + status: wire::FileDiffStatus::Added, + hunks: vec![], + }, + ]; + let text = format_diff(&big); + assert!(text.len() < DIFF_CONTEXT_MAX_BYTES + 1024); + assert!(text.contains("[diff truncated")); + assert!( + text.contains("- src/other.rs"), + "file list survives truncation" + ); + + assert_eq!(format_diff(&[]), "(no changes in the working copy)"); + } + + #[test] + fn summary_clip_keeps_tail() { + let long = format!("{}THE-END", "x".repeat(SUMMARY_CONTEXT_MAX_BYTES * 2)); + let clipped = clip_summary(&long); + assert!(clipped.len() <= SUMMARY_CONTEXT_MAX_BYTES + 32); + assert!(clipped.ends_with("THE-END")); + assert!(clipped.starts_with("[…truncated…]")); + assert_eq!(clip_summary("short"), "short"); + } + + #[test] + fn wire_info_reflects_state() { + let mut run = run_for("name: My flow\nreview:\n max_rounds: 2\n"); + run.state = RunState::Running { + stage: StageKind::Implement, + }; + let info = run.wire_info(); + assert_eq!(info.stage, wire::WorkflowStage::Implement); + assert!(info.waiting.is_none()); + assert_eq!(info.max_rounds, 2); + + run.extra_rounds = 2; + run.state = RunState::AwaitingReply { + stage: StageKind::Plan, + child: "t_c".into(), + question: "which db?".into(), + }; + let info = run.wire_info(); + assert_eq!(info.max_rounds, 4); + let waiting = info.waiting.unwrap(); + assert_eq!(waiting.kind, wire::WorkflowWaitKind::Question); + assert_eq!(waiting.question.as_deref(), Some("which db?")); + + run.open_findings = vec![Finding { + severity: Severity::High, + file: None, + description: "d".into(), + reviewer: "r".into(), + }]; + run.state = RunState::AwaitingLimitDecision; + let waiting = run.wire_info().waiting.unwrap(); + assert_eq!(waiting.kind, wire::WorkflowWaitKind::Limit); + assert_eq!(waiting.question.as_deref(), Some("open findings: 1 high")); + + run.state = RunState::Paused { + next: StageKind::Fix, + }; + let info = run.wire_info(); + assert_eq!(info.stage, wire::WorkflowStage::Fix); + assert_eq!(info.waiting.unwrap().kind, wire::WorkflowWaitKind::Paused); + } + + #[test] + fn graph_info_maps_history() { + let mut run = run_for("name: X\n"); + run.record_stage(StageKind::Implement, "t_1", "claude", "implement".into()); + run.set_record_status("t_1", wire::OrchNodeStatus::Complete); + run.record_stage( + StageKind::Review, + "t_2", + "codex", + "reviewer 1/2 (codex)".into(), + ); + let graph = run.graph_info(); + assert_eq!(graph.goal, "X"); + assert_eq!(graph.nodes.len(), 2); + assert_eq!(graph.nodes[0].status, wire::OrchNodeStatus::Complete); + assert_eq!(graph.nodes[0].kind, wire::OrchNodeKind::Implement); + assert_eq!(graph.nodes[1].task_id.as_deref(), Some("t_2")); + assert_eq!(graph.nodes[1].kind, wire::OrchNodeKind::Review); + } + + #[test] + fn run_serialization_roundtrip() { + let mut run = run_for("name: X\nplan: {}\n"); + run.state = RunState::Paused { + next: StageKind::Review, + }; + run.round = 2; + run.open_findings = vec![Finding { + severity: Severity::Critical, + file: Some("a.rs".into()), + description: "boom".into(), + reviewer: "r".into(), + }]; + let json = serde_json::to_string(&run).unwrap(); + let restored: WorkflowRun = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.state, run.state); + assert_eq!(restored.round, 2); + assert_eq!(restored.spec, run.spec); + assert_eq!(restored.open_findings, run.open_findings); + } +} diff --git a/src/workflow_config.rs b/src/workflow_config.rs index cb7c2c3..9412c1f 100644 --- a/src/workflow_config.rs +++ b/src/workflow_config.rs @@ -11,7 +11,7 @@ //! sync code unit-testable in isolation. use anyhow::{bail, Context, Result}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; @@ -36,7 +36,7 @@ pub const BUILTIN_WORKFLOWS: &[(&str, &str)] = &[ // ─── Validated model ───────────────────────────────────────────────────────── -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct WorkflowSpec { /// File stem for project workflows, registry key for built-ins. pub id: String, @@ -52,14 +52,14 @@ pub struct WorkflowSpec { /// Per-stage overrides. `None` falls back to the lead agent / model picked in /// the New Task dialog (for `fix`: to the `implement` stage's values) and to /// the built-in stage prompt. -#[derive(Debug, Clone, Default, PartialEq)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct StageConfig { pub agent: Option, pub model: Option, pub prompt: Option, } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ReviewConfig { pub max_rounds: u32, pub on_limit: OnLimit, @@ -69,7 +69,8 @@ pub struct ReviewConfig { } /// What the pipeline does when `max_rounds` is exhausted with open findings. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum OnLimit { /// Suspend and ask the user (extend / finish / stop). Ask, @@ -78,7 +79,8 @@ pub enum OnLimit { } /// One piece of context assembled into a reviewer's prompt. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum ReviewContextItem { /// The task prompt the user typed. Prompt, @@ -90,7 +92,7 @@ pub enum ReviewContextItem { Diff, } -#[derive(Debug, Clone, Default, PartialEq)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct ReviewerConfig { pub agent: Option, pub model: Option, diff --git a/tests/fixtures/mock-acp-workflow.mjs b/tests/fixtures/mock-acp-workflow.mjs new file mode 100644 index 0000000..fa85498 --- /dev/null +++ b/tests/fixtures/mock-acp-workflow.mjs @@ -0,0 +1,88 @@ +// Scripted ACP agent for workflow-engine tests. +// +// Usage: node mock-acp-workflow.mjs [behavior...] +// +// Each session/prompt consumes the next behavior from the shared state file, +// so consecutive stage processes driven by the same agent command (implement → +// fix, review round 1 → review round 2) continue one script. Prompts past the +// end of the script repeat the last behavior. +import { readFileSync, writeFileSync } from "node:fs"; + +const [stateFile, ...script] = process.argv.slice(2); +let buf = ""; +const SID = "mock-session-workflow"; + +const send = (obj) => process.stdout.write(JSON.stringify(obj) + "\n"); +const update = (u) => + send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: SID, update: u } }); +const text = (t) => + update({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: t } }); +const endTurn = (id) => send({ jsonrpc: "2.0", id, result: { stopReason: "end_turn" } }); + +const nextBehavior = () => { + let index = 0; + try { + index = parseInt(readFileSync(stateFile, "utf8"), 10) || 0; + } catch {} + writeFileSync(stateFile, String(index + 1)); + return script[Math.min(index, script.length - 1)]; +}; + +process.stdin.on("data", (chunk) => { + buf += chunk; + let i; + while ((i = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, i).trim(); + buf = buf.slice(i + 1); + if (line) handle(JSON.parse(line)); + } +}); + +function handle(msg) { + if (msg.method === "initialize") { + send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1, agentCapabilities: {} } }); + } else if (msg.method === "session/new") { + send({ jsonrpc: "2.0", id: msg.id, result: { sessionId: SID } }); + } else if (msg.method === "session/prompt") { + const behavior = nextBehavior(); + switch (behavior) { + case "impl": + text("IMPL-DONE: implemented the change."); + endTurn(msg.id); + break; + case "slow-impl": + text("IMPL-DONE: implemented the change (slowly)."); + setTimeout(() => endTurn(msg.id), 600); + break; + case "fix": + text("FIX-DONE: addressed the findings."); + endTurn(msg.id); + break; + case "plan": + text("PLAN: 1. edit a.rs 2. run tests"); + endTurn(msg.id); + break; + case "question": + text('Before I plan this:\n```json\n{"need_user_input": "Which database?"}\n```'); + endTurn(msg.id); + break; + case "approve": + text('Looks good.\n```json\n{"verdict": "approve", "findings": []}\n```'); + endTurn(msg.id); + break; + case "reject": + text( + 'Found problems.\n```json\n{"verdict": "request_changes", "findings": [{"severity": "high", "file": "a.rs", "description": "bug here"}]}\n```' + ); + endTurn(msg.id); + break; + case "garbage": + text("looks fine to me, ship it"); + endTurn(msg.id); + break; + default: + text(`unknown behavior: ${behavior}`); + endTurn(msg.id); + } + } +} From 958a063aec7761616ac98ef22f2b5df454a696a3 Mon Sep 17 00:00:00 2001 From: Alexandr Shelevalnyk Date: Wed, 29 Jul 2026 16:51:29 +0200 Subject: [PATCH 3/8] feat(workflows): pick and steer pipelines from the desktop app PR 3 of 3. New Task dialog: - Workflow pill-dropdown listing project templates plus built-ins; invalid templates stay listed with their parse error instead of vanishing, and a built-in can be copied into .warpforge/workflows/ for editing - picking a workflow passes it to task.create, relabels the agent chip as the lead agent, and is mutually exclusive with the orchestrator chat - a daemon-side validation failure keeps the dialog open so the prompt survives Task detail: - WorkflowControls above the composer: stage, round, latest verdict, plus Pause/Resume and the extend/finish/stop choice when review rounds run out - the composer routes messages to workflow.reply / resume / decide depending on what the pipeline is waiting for, and is disabled (with an explanatory placeholder) while it runs unattended; the stop button still cancels the run Elsewhere: - a pipeline waiting on a question or a review limit joins the "Needs you" rail just under a pending permission; a user-initiated pause stays out - board cards get a stage badge that turns amber when the pipeline needs you Tests cover the picker (selection, invalid entries, eject, mutual exclusion), composer routing per waiting kind, the controls, and the attention rule. --- .changeset/olive-pipelines-steer.md | 18 ++ desktop/src/components/ChatComposer.test.tsx | 117 ++++++++++ desktop/src/components/ChatComposer.tsx | 53 ++++- desktop/src/components/ChatTranscript.tsx | 2 + .../src/components/TaskComposeBar.test.tsx | 123 +++++++++++ desktop/src/components/TaskComposeBar.tsx | 165 +++++++++++++- .../src/components/WorkflowControls.test.tsx | 96 ++++++++ desktop/src/components/WorkflowControls.tsx | 207 ++++++++++++++++++ desktop/src/daemon.ts | 50 ++++- desktop/src/lib/attentionRail.test.ts | 54 +++++ desktop/src/lib/attentionRail.ts | 13 ++ desktop/src/protocol.ts | 54 ++++- desktop/src/views/Board.tsx | 42 ++++ desktop/src/views/NewTaskDialog.tsx | 94 ++++++-- 14 files changed, 1058 insertions(+), 30 deletions(-) create mode 100644 .changeset/olive-pipelines-steer.md create mode 100644 desktop/src/components/ChatComposer.test.tsx create mode 100644 desktop/src/components/TaskComposeBar.test.tsx create mode 100644 desktop/src/components/WorkflowControls.test.tsx create mode 100644 desktop/src/components/WorkflowControls.tsx diff --git a/.changeset/olive-pipelines-steer.md b/.changeset/olive-pipelines-steer.md new file mode 100644 index 0000000..8a88b20 --- /dev/null +++ b/.changeset/olive-pipelines-steer.md @@ -0,0 +1,18 @@ +--- +"warpforge": minor +--- + +Workflows are now selectable and steerable from the app. The New Task dialog +gets a Workflow picker listing your project's templates alongside the built-in +ones — a built-in can be copied into the project in one click so you can edit +it, and a template with a broken YAML is listed with its error rather than +quietly missing. Picking a workflow runs the task as a pipeline instead of a +single agent session, and the agent chip becomes the lead agent for any stage +the workflow doesn't assign. + +A running pipeline shows its stage, round, and latest review verdict above the +composer, with Pause and Resume. When it needs you — a stage asked a question, +or review rounds ran out — the task surfaces in "Needs you" and the composer +turns into the answer box: typing replies to the asking stage, resumes a pause +with your note as guidance, or buys another fix round. Board cards carry a +stage badge that turns amber when the pipeline is waiting on you. diff --git a/desktop/src/components/ChatComposer.test.tsx b/desktop/src/components/ChatComposer.test.tsx new file mode 100644 index 0000000..f61dca7 --- /dev/null +++ b/desktop/src/components/ChatComposer.test.tsx @@ -0,0 +1,117 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { TaskInfo, WorkflowRunInfo, WorkflowWaitKind } from "../protocol"; +import { ChatComposer } from "./ChatComposer"; + +const request = vi.fn<(...args: unknown[]) => Promise>(async () => ({})); +const workflowReply = vi.fn<(...args: unknown[]) => Promise>(async () => {}); +const workflowResume = vi.fn<(...args: unknown[]) => Promise>(async () => {}); +const workflowDecide = vi.fn<(...args: unknown[]) => Promise>(async () => {}); + +vi.mock("../daemon", () => ({ + daemon: { + request: (...args: unknown[]) => request(...(args as [])), + workflowDecide: (...args: unknown[]) => workflowDecide(...(args as [])), + workflowReply: (...args: unknown[]) => workflowReply(...(args as [])), + workflowResume: (...args: unknown[]) => workflowResume(...(args as [])), + }, +})); + +function task(workflowRun?: WorkflowRunInfo | null): TaskInfo { + return { + agent: "claude", + blockedReason: null, + createdAt: 1, + filesChanged: 0, + id: "t_1", + project: "warpforge", + prompt: "do it", + status: "running", + tags: [], + title: "", + updatedAt: 1, + workflowRun, + }; +} + +function run(waiting?: WorkflowWaitKind): WorkflowRunInfo { + return { + maxRounds: 2, + round: 1, + stage: waiting === "question" ? "plan" : "review", + waiting: waiting ? { kind: waiting } : null, + workflowId: "wf", + workflowName: "Review loop", + }; +} + +function renderComposer(t: TaskInfo) { + return render( + void>()} + task={t} + />, + ); +} + +async function send(text: string) { + const user = userEvent.setup(); + await user.type(screen.getByRole("textbox"), text); + await user.click(screen.getByRole("button", { name: /send/i })); +} + +describe("ChatComposer — workflow parents", () => { + beforeEach(() => vi.clearAllMocks()); + + it("prompts the agent session for a regular task", async () => { + renderComposer(task(null)); + await send("hello"); + expect(request).toHaveBeenCalledWith( + "session.prompt", + expect.objectContaining({ task_id: "t_1", text: "hello" }), + ); + expect(workflowReply).not.toHaveBeenCalled(); + }); + + it("routes a message to the asking stage when a question is pending", async () => { + renderComposer(task(run("question"))); + await send("Postgres"); + expect(workflowReply).toHaveBeenCalledWith("t_1", "Postgres"); + expect(request).not.toHaveBeenCalledWith("session.prompt", expect.anything()); + }); + + it("resumes a paused pipeline, passing the message as guidance", async () => { + renderComposer(task(run("paused"))); + await send("prefer the simpler fix"); + expect(workflowResume).toHaveBeenCalledWith("t_1", "prefer the simpler fix"); + }); + + it("turns a message at the review limit into one more round with guidance", async () => { + renderComposer(task(run("limit"))); + await send("focus on the parser"); + expect(workflowDecide).toHaveBeenCalledWith("t_1", "extend", { + note: "focus on the parser", + rounds: 1, + }); + }); + + it("disables input while the pipeline runs unattended", () => { + renderComposer(task(run())); + expect(screen.getByRole("textbox")).toBeDisabled(); + expect(screen.getByRole("textbox")).toHaveAttribute( + "placeholder", + expect.stringContaining("Subtasks"), + ); + }); + + it("re-enables input once the pipeline finishes", () => { + renderComposer(task({ ...run(), stage: "done", waiting: null })); + expect(screen.getByRole("textbox")).not.toBeDisabled(); + }); +}); diff --git a/desktop/src/components/ChatComposer.tsx b/desktop/src/components/ChatComposer.tsx index 2c10122..2da5a54 100644 --- a/desktop/src/components/ChatComposer.tsx +++ b/desktop/src/components/ChatComposer.tsx @@ -22,20 +22,50 @@ export const ChatComposer = memo( { commands, contextUsage, files, filesLoading, imageSupported, onBeforeSend, task }, ref, ) { + // A workflow parent has no agent session: its messages steer the pipeline + // instead, and are only accepted at the points where the daemon is waiting + // for a human (see WorkflowControls for the button-driven half). + const run = task.workflowRun ?? null; + const waiting = run?.waiting ?? null; + const onSend = useCallback( async (submission: PromptSubmission) => { onBeforeSend(); + const text = submission.text.trim(); + if (waiting) { + switch (waiting.kind) { + case "question": + await daemon.workflowReply(task.id, text); + return; + case "paused": + await daemon.workflowResume(task.id, text || undefined); + return; + case "limit": + // Typed guidance rides along with one more round of fixes. + await daemon.workflowDecide(task.id, "extend", { + note: text || undefined, + rounds: 1, + }); + return; + } + } await daemon.request("session.prompt", { task_id: task.id, ...submission }); }, - [onBeforeSend, task.id], + [onBeforeSend, task.id, waiting], ); + // For a workflow parent the daemon maps task.cancel onto stopping the + // whole pipeline, so the composer's stop button stays the terminal + // "kill it" affordance next to WorkflowControls' soft pause. const onCancel = useCallback( () => void daemon.request("task.cancel", { task_id: task.id }), [task.id], ); const isRunning = task.status === "running" || task.status === "queued"; + // While a pipeline runs unattended there is nobody to read a message — + // intervene in a stage's own session (Subtasks) instead. + const workflowBusy = !!run && !waiting && run.stage !== "done" && run.stage !== "failed"; return ( 0 ? ( @@ -57,3 +88,21 @@ export const ChatComposer = memo( ); }), ); + +/** `undefined` keeps the Composer's own default for non-workflow tasks. */ +function workflowPlaceholder( + kind: "question" | "limit" | "paused" | undefined, + busy: boolean, +): string | undefined { + if (busy) return "The pipeline is running — open a stage under Subtasks to steer it."; + switch (kind) { + case "question": + return "Answer the stage's question…"; + case "paused": + return "Add guidance for the next stage, then send to resume…"; + case "limit": + return "Add guidance for another fix round, or pick an option above…"; + default: + return undefined; + } +} diff --git a/desktop/src/components/ChatTranscript.tsx b/desktop/src/components/ChatTranscript.tsx index ab3d766..cc953b2 100644 --- a/desktop/src/components/ChatTranscript.tsx +++ b/desktop/src/components/ChatTranscript.tsx @@ -42,6 +42,7 @@ import { AgentActivityIndicator } from "./AgentActivityIndicator"; import { ChatComposer } from "./ChatComposer"; import type { ComposerHandle } from "./Composer"; import { MessageActions } from "./MessageActions"; +import { WorkflowControls } from "./WorkflowControls"; const CHAT_DRAW_DISTANCE_PX = 250; const CHAT_MAINTAIN_SCROLL_AT_END = { @@ -486,6 +487,7 @@ export function ChatTranscript({ )} + {task.workflowRun && } void>(); +const onEjectWorkflow = vi.fn<(id: string) => void>(); +const onOrchChatChange = vi.fn<(v: boolean) => void>(); + +const workflows: WorkflowMeta[] = [ + { + description: "Implement, then loop reviews and fixes.", + id: "review-loop", + maxRounds: 2, + name: "Review loop", + source: "builtin", + stages: ["implement", "review×2", "fix"], + valid: true, + }, + { + error: "`name` is required", + id: "broken", + name: "broken", + source: "project", + valid: false, + }, +]; + +function renderBar(props: Partial[0]> = {}) { + return render( + void>()} + onEjectWorkflow={onEjectWorkflow} + onOrchChatChange={onOrchChatChange} + onProjectChange={vi.fn<(v: string) => void>()} + onShareContextChange={vi.fn<(v: boolean) => void>()} + onUseWorktreeChange={vi.fn<(v: boolean) => void>()} + onWorkflowChange={onWorkflowChange} + orchChat={false} + project="warpforge" + projects={[ + { + agentTemplates: {}, + declaredServices: [], + name: "warpforge", + path: "/tmp/warpforge", + portRange: [4000, 4099], + }, + ]} + services={[]} + shareContext + useWorktree={false} + workflow={null} + workflows={workflows} + {...props} + />, + ); +} + +describe("TaskComposeBar — workflow picker", () => { + beforeEach(() => vi.clearAllMocks()); + + it("hides the picker when a project has no workflows", () => { + renderBar({ workflows: [] }); + expect(screen.queryByRole("button", { name: /workflow/i })).not.toBeInTheDocument(); + }); + + it("selects a workflow from the menu", async () => { + const user = userEvent.setup(); + renderBar(); + await user.click(screen.getByRole("button", { name: /workflow/i })); + await user.click(screen.getByRole("menuitem", { name: /Review loop/ })); + expect(onWorkflowChange).toHaveBeenCalledWith("review-loop"); + }); + + it("lists an invalid workflow with its error but does not select it", async () => { + const user = userEvent.setup(); + renderBar(); + await user.click(screen.getByRole("button", { name: /workflow/i })); + const broken = screen.getByRole("menuitem", { name: /`name` is required/ }); + expect(broken).toHaveAttribute("aria-disabled", "true"); + await user.click(broken); + expect(onWorkflowChange).not.toHaveBeenCalled(); + }); + + it("copies a built-in into the project without selecting it", async () => { + const user = userEvent.setup(); + renderBar(); + await user.click(screen.getByRole("button", { name: /workflow/i })); + await user.click(screen.getByRole("menuitem", { name: /copy to project/i })); + expect(onEjectWorkflow).toHaveBeenCalledWith("review-loop"); + expect(onWorkflowChange).not.toHaveBeenCalled(); + }); + + it("summarizes the selected workflow and relabels the agent as the lead", () => { + renderBar({ workflow: "review-loop" }); + expect(screen.getByText(/implement → review×2 → fix/)).toBeInTheDocument(); + expect(screen.getByText(/up to 2 review rounds/)).toBeInTheDocument(); + expect(screen.getByText("Lead agent")).toBeInTheDocument(); + }); + + it("locks the orchestrator toggle while a workflow is selected", () => { + renderBar({ workflow: "review-loop" }); + expect(screen.getByRole("button", { name: /orchestrator/i })).toBeDisabled(); + }); + + it("locks the workflow picker while the orchestrator is on", () => { + renderBar({ orchChat: true }); + expect(screen.getByRole("button", { name: /workflow/i })).toBeDisabled(); + }); +}); diff --git a/desktop/src/components/TaskComposeBar.tsx b/desktop/src/components/TaskComposeBar.tsx index 66d0d29..d1e11af 100644 --- a/desktop/src/components/TaskComposeBar.tsx +++ b/desktop/src/components/TaskComposeBar.tsx @@ -1,8 +1,26 @@ -import { GitBranch, GitMerge, Share2 } from "lucide-react"; +import { + AlertTriangle, + Check, + ChevronDown, + Copy, + GitBranch, + GitMerge, + Route, + Share2, +} from "lucide-react"; +import { Fragment } from "react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; -import type { AgentConfig, ProjectInfo, ServiceInfo } from "../protocol"; +import type { AgentConfig, ProjectInfo, ServiceInfo, WorkflowMeta } from "../protocol"; import { AgentBadge } from "./AgentBadge"; interface TaskComposeBarProps { @@ -15,12 +33,18 @@ interface TaskComposeBarProps { shareContext: boolean; useWorktree: boolean; orchChat: boolean; + /** Available workflow templates for the selected project. */ + workflows: WorkflowMeta[]; + /** Selected workflow id, or null for a plain single-agent task. */ + workflow: string | null; onProjectChange: (v: string) => void; onAgentChange: (v: string) => void; onShareContextChange: (v: boolean) => void; onUseWorktreeChange: (v: boolean) => void; onOrchChatChange: (v: boolean) => void; + onWorkflowChange: (v: string | null) => void; + onEjectWorkflow: (id: string) => void; } /** @@ -42,11 +66,15 @@ export function TaskComposeBar({ shareContext, useWorktree, orchChat, + workflows, + workflow, onProjectChange, onAgentChange, onShareContextChange, onUseWorktreeChange, onOrchChatChange, + onWorkflowChange, + onEjectWorkflow, }: TaskComposeBarProps) { const enabledAgents = agents.filter((a) => a.enabled); const agentChoices = @@ -54,6 +82,7 @@ export function TaskComposeBar({ const runningForProject = services.filter( (s) => s.project === project && s.status === "running" && s.allocatedPort > 0, ); + const selectedWorkflow = workflows.find((w) => w.id === workflow) ?? null; return (
@@ -71,7 +100,7 @@ export function TaskComposeBar({
- {orchChat ? "Lead agent" : "Agent"} + {orchChat || workflow ? "Lead agent" : "Agent"} {agentChoices.map((a) => ( onAgentChange(a.id)}> @@ -101,12 +130,30 @@ export function TaskComposeBar({ /> onOrchChatChange(!orchChat)} icon={} label="Orchestrator" - tooltip="Chat + sub-agents" + tooltip={workflow ? "Not available with a workflow selected" : "Chat + sub-agents"} + /> +
+ {selectedWorkflow && ( +

+ {selectedWorkflow.description ? `${selectedWorkflow.description} ` : ""} + {(selectedWorkflow.stages ?? []).join(" \u2192 ")} + {selectedWorkflow.maxRounds + ? `, up to ${selectedWorkflow.maxRounds} review round${selectedWorkflow.maxRounds === 1 ? "" : "s"}` + : ""} + {". The agent above leads any stage the workflow doesn\u2019t assign."} +

+ )} ); } @@ -180,3 +227,113 @@ function PillToggle({ ); } + +/** + * Workflow template picker. A workflow replaces the single-agent run with a + * daemon-driven pipeline, so it is mutually exclusive with the orchestrator + * chat. Invalid templates stay listed (with their parse error) so a typo in a + * project's YAML is visible here rather than silently missing. + */ +function WorkflowPicker({ + workflows, + selected, + disabled, + onSelect, + onEject, +}: { + workflows: WorkflowMeta[]; + selected: WorkflowMeta | null; + disabled?: boolean; + onSelect: (id: string | null) => void; + onEject: (id: string) => void; +}) { + if (workflows.length === 0) return null; + return ( + + + + + + onSelect(null)}> + + + + No workflow + + One agent works the task directly. + + + + + + + Pipelines + + {workflows.map((w) => ( + + onSelect(w.id)} + > + + + + + {w.name} + {w.source === "builtin" && ( + + built-in + + )} + {!w.valid && } + + + {w.valid ? (w.stages ?? []).join(" \u2192 ") : (w.error ?? "invalid workflow")} + + + + + {/* Ejecting is its own row: a nested button inside a menu item + never receives the click, because Radix closes the menu first. */} + {w.source === "builtin" && w.valid && ( + onEject(w.id)} + > + + + Copy to project + + + )} + + ))} + + + ); +} diff --git a/desktop/src/components/WorkflowControls.test.tsx b/desktop/src/components/WorkflowControls.test.tsx new file mode 100644 index 0000000..aea241d --- /dev/null +++ b/desktop/src/components/WorkflowControls.test.tsx @@ -0,0 +1,96 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { TaskInfo, WorkflowRunInfo } from "../protocol"; +import { WorkflowControls } from "./WorkflowControls"; + +const workflowPause = vi.fn<(...args: unknown[]) => Promise>(async () => {}); +const workflowResume = vi.fn<(...args: unknown[]) => Promise>(async () => {}); +const workflowDecide = vi.fn<(...args: unknown[]) => Promise>(async () => {}); + +vi.mock("../daemon", () => ({ + daemon: { + workflowDecide: (...args: unknown[]) => workflowDecide(...(args as [])), + workflowPause: (...args: unknown[]) => workflowPause(...(args as [])), + workflowResume: (...args: unknown[]) => workflowResume(...(args as [])), + }, +})); + +function task(run: Partial): TaskInfo { + return { + agent: "claude", + blockedReason: null, + createdAt: 1, + filesChanged: 0, + id: "t_1", + project: "warpforge", + prompt: "do it", + status: "running", + tags: [], + title: "", + updatedAt: 1, + workflowRun: { + maxRounds: 2, + round: 1, + stage: "review", + workflowId: "wf", + workflowName: "Review loop", + ...run, + }, + }; +} + +describe("WorkflowControls", () => { + beforeEach(() => vi.clearAllMocks()); + + it("shows the pipeline position and pauses a running stage", async () => { + render(); + expect(screen.getByText("Review loop")).toBeInTheDocument(); + expect(screen.getByText("reviewing")).toBeInTheDocument(); + expect(screen.getByText("round 1/2")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: /pause/i })); + expect(workflowPause).toHaveBeenCalledWith("t_1"); + }); + + it("offers Resume instead of Pause when paused", async () => { + render(); + expect(screen.queryByRole("button", { name: /pause/i })).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: /resume/i })); + expect(workflowResume).toHaveBeenCalledWith("t_1"); + }); + + it("offers extend / finish / stop when the review limit is reached", async () => { + render( + , + ); + expect(screen.getByText(/open findings: 2 high/)).toBeInTheDocument(); + // Pausing makes no sense while a decision is pending. + expect(screen.queryByRole("button", { name: /pause/i })).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: /2 more rounds/i })); + expect(workflowDecide).toHaveBeenCalledWith("t_1", "extend", { rounds: 2 }); + + await userEvent.click(screen.getByRole("button", { name: /finish as is/i })); + expect(workflowDecide).toHaveBeenCalledWith("t_1", "finish"); + + await userEvent.click(screen.getByRole("button", { name: /stop/i })); + expect(workflowDecide).toHaveBeenCalledWith("t_1", "stop"); + }); + + it("drops the controls once the pipeline is finished", () => { + render(); + expect(screen.queryByRole("button", { name: /pause/i })).not.toBeInTheDocument(); + expect(screen.getByText("approved")).toBeInTheDocument(); + }); + + it("renders nothing for a task without a pipeline", () => { + const plain = { ...task({}), workflowRun: null }; + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/desktop/src/components/WorkflowControls.tsx b/desktop/src/components/WorkflowControls.tsx new file mode 100644 index 0000000..d71df60 --- /dev/null +++ b/desktop/src/components/WorkflowControls.tsx @@ -0,0 +1,207 @@ +import { CircleCheckBig, Pause, Play, Plus, Square } from "lucide-react"; +import { memo, useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +import { daemon } from "../daemon"; +import type { TaskInfo, WorkflowRunInfo } from "../protocol"; + +/** + * Pipeline status strip + controls for a workflow parent task. + * + * A workflow parent has no agent session of its own — the daemon drives its + * stages — so this bar (not the composer) is where the pipeline is steered. + * The composer takes over only when a stage asks a question; see + * `ChatComposer`. + */ +export const WorkflowControls = memo(function WorkflowControls({ task }: { task: TaskInfo }) { + const run = task.workflowRun; + const [busy, setBusy] = useState(false); + if (!run) return null; + + const waiting = run.waiting ?? null; + const finished = run.stage === "done" || run.stage === "failed"; + + const act = async (label: string, fn: () => Promise) => { + setBusy(true); + try { + await fn(); + } catch (e) { + toast.error(`Could not ${label}`, { description: String(e) }); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ + + {!finished && waiting?.kind !== "limit" && ( + <> + {waiting?.kind === "paused" ? ( + + ) : ( + + )} + + )} + +
+ + {waiting?.kind === "limit" && ( + + )} + + {waiting?.kind === "paused" && ( +

+ Paused before the {stageLabel(run.stage)} stage. Type a message to resume with it as + guidance, or press Resume. +

+ )} +
+ ); +}); + +/** Buttons shown when review rounds ran out with findings still open. */ +function LimitDecision({ + task, + summary, + busy, + act, +}: { + task: TaskInfo; + summary: string; + busy: boolean; + act: (label: string, fn: () => Promise) => Promise; +}) { + return ( +
+

+ Review rounds are used up{summary ? ` — ${summary}` : ""}. What next? +

+

+ A message you type below is passed to the next fix attempt as guidance. +

+
+ + + + +
+
+ ); +} + +function StageIndicator({ run }: { run: WorkflowRunInfo }) { + const waiting = run.waiting ?? null; + return ( + + {run.workflowName} + · + + {waiting?.kind === "paused" + ? `paused before ${stageLabel(run.stage)}` + : stageLabel(run.stage)} + + {run.round > 0 && run.stage !== "done" && run.stage !== "failed" && ( + + round {run.round}/{run.maxRounds} + + )} + {run.verdict && ( + + {run.verdict === "approve" ? "approved" : "changes requested"} + + )} + + ); +} + +export function stageLabel(stage: WorkflowRunInfo["stage"]): string { + switch (stage) { + case "plan": + return "planning"; + case "implement": + return "implementing"; + case "review": + return "reviewing"; + case "fix": + return "fixing"; + case "done": + return "done"; + case "failed": + return "failed"; + } +} diff --git a/desktop/src/daemon.ts b/desktop/src/daemon.ts index fa16247..098b87c 100644 --- a/desktop/src/daemon.ts +++ b/desktop/src/daemon.ts @@ -1061,13 +1061,53 @@ export class DaemonClient { return (result as { ok?: boolean })?.ok ?? false; } + // ── Workflow RPCs ── + + /** Workflows selectable for a project: its own files plus built-ins. */ + async workflowList(project: string): Promise { + const result = await this.request("workflow.list", { project }); + const workflows = (result as { workflows?: import("./protocol").WorkflowMeta[] })?.workflows; + return Array.isArray(workflows) ? workflows : []; + } + + /** Copy a built-in workflow into the project so it can be customized. */ + async workflowEject(project: string, id: string): Promise { + const result = await this.request("workflow.eject", { id, project }); + return (result as { path?: string })?.path ?? ""; + } + + /** Soft-pause a pipeline: the running stage finishes, the next won't start. */ + async workflowPause(task: string): Promise { + await this.request("workflow.pause", { task }); + } + + /** Resume a paused pipeline; `note` reaches the next stage as guidance. */ + async workflowResume(task: string, note?: string): Promise { + await this.request("workflow.resume", { note, task }); + } + + /** Answer a stage's pending question. */ + async workflowReply(task: string, message: string): Promise { + await this.request("workflow.reply", { message, task }); + } + + /** Decide what an out-of-rounds pipeline does next. */ + async workflowDecide( + task: string, + decision: import("./protocol").WorkflowDecision, + opts?: { rounds?: number; note?: string }, + ): Promise { + await this.request("workflow.decide", { + decision, + note: opts?.note, + rounds: opts?.rounds, + task, + }); + } + // ── Terminal PTY RPCs ── - async spawnTerminal( - project: string, - cols: number, - rows: number, - ): Promise { + async spawnTerminal(project: string, cols: number, rows: number): Promise { const result = await this.request("terminal.spawn", { project, command: 'exec "${SHELL:-/bin/sh}" -l', diff --git a/desktop/src/lib/attentionRail.test.ts b/desktop/src/lib/attentionRail.test.ts index 0a6d04f..797bb02 100644 --- a/desktop/src/lib/attentionRail.test.ts +++ b/desktop/src/lib/attentionRail.test.ts @@ -53,6 +53,20 @@ describe("taskStatusRank", () => { }); }); +function waitingRun( + kind: "question" | "limit" | "paused", + question?: string, +): NonNullable { + return { + maxRounds: 2, + round: 1, + stage: "review", + waiting: { kind, question }, + workflowId: "wf", + workflowName: "Review loop", + }; +} + describe("buildAttentionQueue", () => { it("orders permission > review > blocked > interrupted", () => { const tasks = [ @@ -88,6 +102,46 @@ describe("buildAttentionQueue", () => { }); }); +describe("buildAttentionQueue — workflow pipelines", () => { + it("queues a pipeline waiting on a question, using the question as the reason", () => { + const t = task("wf", { status: "running", workflowRun: waitingRun("question", "Which db?") }); + const [item] = buildAttentionQueue([t], {}); + expect(item.reason).toBe("Which db?"); + expect(item.task.id).toBe("wf"); + }); + + it("queues an exhausted review limit with its findings summary", () => { + const t = task("wf", { + status: "running", + workflowRun: waitingRun("limit", "open findings: 2 high"), + }); + const [item] = buildAttentionQueue([t], {}); + expect(item.reason).toBe("review limit reached — open findings: 2 high"); + }); + + it("leaves a user-initiated pause out of the queue", () => { + const t = task("wf", { status: "running", workflowRun: waitingRun("paused") }); + expect(buildAttentionQueue([t], {})).toEqual([]); + }); + + it("ranks a waiting pipeline under a permission but above needs_review", () => { + const tasks = [ + task("review", { status: "needs_review" }), + task("wf", { status: "running", workflowRun: waitingRun("question", "?") }), + task("perm", { status: "running" }), + ]; + const queue = buildAttentionQueue(tasks, { perm: [permUpdate()] }); + expect(queue.map((i) => i.task.id)).toEqual(["perm", "wf", "review"]); + }); + + it("still reports a pending permission on a workflow stage child", () => { + const t = task("wf", { status: "running", workflowRun: waitingRun("question", "?") }); + const [item] = buildAttentionQueue([t], { wf: [permUpdate("p", "Write file?")] }); + expect(item.reason).toBe("Write file?"); + expect(item.permission).toBeDefined(); + }); +}); + describe("selectRailTasks", () => { const attention = (ids: string[]): Map => new Map(ids.map((id) => [id, { priority: 1, reason: "test", task: task(id) }])); diff --git a/desktop/src/lib/attentionRail.ts b/desktop/src/lib/attentionRail.ts index f26fb4d..6f6f127 100644 --- a/desktop/src/lib/attentionRail.ts +++ b/desktop/src/lib/attentionRail.ts @@ -49,8 +49,21 @@ export function buildAttentionQueue( prunePermissionCache(new Set(tasks.map((task) => task.id))); for (const task of tasks) { const perm = latestPendingPermission(task.id, sessionUpdates[task.id]); + const waiting = task.workflowRun?.waiting ?? null; if (perm) { items.push({ permission: perm, priority: 0, reason: perm.title, task }); + } else if (waiting && waiting.kind !== "paused") { + // A pipeline that suspended itself is asking directly, so it ranks just + // under a permission prompt. A pause is user-initiated — the user knows + // it is waiting, so it stays out of the queue. + items.push({ + priority: 0.5, + reason: + waiting.kind === "question" + ? (waiting.question ?? "workflow needs your input") + : `review limit reached${waiting.question ? ` — ${waiting.question}` : ""}`, + task, + }); } else if (task.status === "needs_review") { items.push({ priority: 1, reason: "finished — review changes", task }); } else if (task.status === "blocked") { diff --git a/desktop/src/protocol.ts b/desktop/src/protocol.ts index 7024e18..93c79cd 100644 --- a/desktop/src/protocol.ts +++ b/desktop/src/protocol.ts @@ -192,8 +192,10 @@ export interface TaskInfo { configOptions?: ConfigOption[]; /** Path to the git worktree for this task, if isolated. */ worktree?: string | null; - /** Orchestration graph for parent orchestrator tasks. */ + /** Orchestration graph for parent orchestrator tasks, and for workflow parents. */ orchestrationGraph?: OrchGraphInfo | null; + /** Live pipeline state when this task is a workflow parent. */ + workflowRun?: WorkflowRunInfo | null; /** Task that spawned this sub-agent through the orchestrator MCP. */ parentTaskId?: string | null; /** Explicit settle override (true = settled, false = not settled). */ @@ -256,6 +258,56 @@ export interface OrchReviewerPool { agent: string; } +// ── Workflow DTOs ────────────────────────────────────────────────────────── + +/** One selectable workflow template, from `workflow.list`. */ +export interface WorkflowMeta { + id: string; + name: string; + description?: string | null; + source: WorkflowSource; + /** False when the YAML failed to parse or validate — listed but unselectable. */ + valid: boolean; + error?: string | null; + /** Non-fatal issues (unknown keys, clamped values). */ + warnings?: string[]; + /** Stage names for the picker tooltip, e.g. ["plan","implement","review\u00d72","fix"]. */ + stages?: string[]; + maxRounds?: number; +} + +export type WorkflowSource = "project" | "builtin"; + +/** Live state of a workflow pipeline, carried on its parent task. */ +export interface WorkflowRunInfo { + workflowId: string; + workflowName: string; + stage: WorkflowStage; + /** Current review round, 1-based; 0 until the first review starts. */ + round: number; + /** Round limit including any user-granted extensions. */ + maxRounds: number; + verdict?: WorkflowVerdict | null; + /** Set while the pipeline waits for the user. */ + waiting?: WorkflowWaiting | null; +} + +export type WorkflowStage = "plan" | "implement" | "review" | "fix" | "done" | "failed"; + +export type WorkflowVerdict = "approve" | "request_changes"; + +export interface WorkflowWaiting { + kind: WorkflowWaitKind; + /** Which stage asked (for `question`). */ + stage?: WorkflowStage | null; + /** The question text, or a findings summary for `limit`. */ + question?: string | null; +} + +export type WorkflowWaitKind = "question" | "limit" | "paused"; + +export type WorkflowDecision = "extend" | "finish" | "stop"; + export type ToolCallStatus = "pending" | "in_progress" | "completed" | "failed"; export interface PlanEntry { diff --git a/desktop/src/views/Board.tsx b/desktop/src/views/Board.tsx index b08055a..3fc9177 100644 --- a/desktop/src/views/Board.tsx +++ b/desktop/src/views/Board.tsx @@ -7,6 +7,7 @@ import { ChevronRight, GitBranch, Plus, + Route, Workflow, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; @@ -26,6 +27,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { stageLabel } from "@/components/WorkflowControls"; import { elapsed } from "@/lib/status"; import type { BoardLifecycleFilter, TaskTree } from "@/lib/taskGroups"; import { @@ -562,6 +564,7 @@ function TaskCard({
+ {task.project} {task.worktree && }
0 ? ` — round ${run.round}/${run.maxRounds}` : ""}`} + className={cn( + "inline-flex shrink-0 items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium", + needsUser + ? "bg-amber-500/12 text-amber-600 dark:text-amber-400" + : "bg-primary/10 text-primary", + )} + > + + {label} + {run.round > 0 && !needsUser && ( + + {run.round}/{run.maxRounds} + + )} + + ); +} + function Empty() { return
No tasks
; } diff --git a/desktop/src/views/NewTaskDialog.tsx b/desktop/src/views/NewTaskDialog.tsx index 4e8a7a9..caf74ac 100644 --- a/desktop/src/views/NewTaskDialog.tsx +++ b/desktop/src/views/NewTaskDialog.tsx @@ -1,4 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { History, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; @@ -11,7 +11,13 @@ import type { ComposerHandle } from "../components/Composer"; import { Composer } from "../components/Composer"; import { TaskComposeBar } from "../components/TaskComposeBar"; import { daemon } from "../daemon"; -import type { ExternalSession, ProjectFile, PromptSubmission, Snapshot } from "../protocol"; +import type { + ExternalSession, + ProjectFile, + PromptSubmission, + Snapshot, + WorkflowMeta, +} from "../protocol"; import { daemonQuery } from "../query"; import { useUi } from "../store/ui"; @@ -35,6 +41,7 @@ export default function NewTaskDialog({ defaultProject, initialPrompt, }: Props) { + const queryClient = useQueryClient(); const openTask = useUi((s) => s.openTask); const autoNameTasks = useUi((s) => s.autoNameTasks); const textGenAgentId = useUi((s) => s.textGenAgentId); @@ -53,6 +60,7 @@ export default function NewTaskDialog({ const [shareContext, setShareContext] = useState(true); const [useWorktree, setUseWorktree] = useState(false); const [orchChat, setOrchChat] = useState(false); + const [workflow, setWorkflow] = useState(null); const composerRef = useRef(null); const agent = enabledAgents.some((candidate) => candidate.id === selectedAgent) @@ -66,6 +74,20 @@ export default function NewTaskDialog({ setProject(nextProject); setSelectedAgent(enabledAgents[0]?.id ?? "claude"); setConfigPicks({}); + // Workflows are per-project files — a pick can't carry over. + setWorkflow(null); + }; + + // A workflow and the orchestrator chat are different engines for the same + // task, so picking one clears the other. + const changeWorkflow = (next: string | null) => { + setWorkflow(next); + if (next) setOrchChat(false); + }; + + const changeOrchChat = (next: boolean) => { + setOrchChat(next); + if (next) setWorkflow(null); }; const changeAgent = (nextAgent: string) => { @@ -99,6 +121,23 @@ export default function NewTaskDialog({ queryKey: ["fileList", "new", project], }); const projectFiles = Array.isArray(filesQuery.data) ? filesQuery.data : []; + const workflowsQuery = useQuery({ + enabled: !!project, + queryFn: () => daemon.workflowList(project), + queryKey: ["workflows", project], + }); + const workflows: WorkflowMeta[] = workflowsQuery.data ?? []; + const selectedWorkflow = workflows.find((w) => w.id === workflow) ?? null; + + const ejectWorkflow = async (id: string) => { + try { + const path = await daemon.workflowEject(project, id); + toast.success("Workflow copied to project", { description: path }); + await queryClient.invalidateQueries({ queryKey: ["workflows", project] }); + } catch (e) { + toast.error("Could not copy workflow", { description: String(e) }); + } + }; const close = useCallback(() => onOpenChange(false), [onOpenChange]); @@ -125,24 +164,35 @@ export default function NewTaskDialog({ const pick = configPicks[opt.id]; if (pick != null) configOverrides[opt.id] = pick; } - const resp = await daemon.request("task.create", { - project, - prompt: submission.text.trim(), - attachments: submission.attachments, - agent, - tags: orchChat ? [...userTags, "orchestrator-chat"] : userTags, - include_runtime_context: shareContext, - worktree: orchChat ? false : useWorktree, - default_model: modelPick, - config_overrides: configOverrides, - }); + let resp: unknown; + try { + resp = await daemon.request("task.create", { + project, + prompt: submission.text.trim(), + attachments: submission.attachments, + agent, + tags: orchChat ? [...userTags, "orchestrator-chat"] : userTags, + include_runtime_context: shareContext, + worktree: orchChat ? false : useWorktree, + default_model: modelPick, + config_overrides: configOverrides, + workflow: workflow ?? undefined, + }); + } catch (e) { + // A workflow can fail validation daemon-side (edited YAML between the + // list and the send) — keep the dialog open so the prompt isn't lost. + toast.error("Could not start the task", { description: String(e) }); + return; + } const taskId = (resp as { taskId?: string } | null)?.taskId ?? (resp as { result?: { taskId?: string } } | null)?.result?.taskId ?? null; if (taskId) { - toast.success("Task started", { - description: `${orchChat ? "Orchestrator" : "Agent"} session created for ${project}`, + toast.success(selectedWorkflow ? "Workflow started" : "Task started", { + description: selectedWorkflow + ? `${selectedWorkflow.name} pipeline running in ${project}` + : `${orchChat ? "Orchestrator" : "Agent"} session created for ${project}`, action: { label: "Open task", onClick: () => openTask(taskId), @@ -205,11 +255,15 @@ export default function NewTaskDialog({ shareContext={shareContext} useWorktree={useWorktree} orchChat={orchChat} + workflows={workflows} + workflow={workflow} onProjectChange={changeProject} onAgentChange={changeAgent} onShareContextChange={setShareContext} onUseWorktreeChange={setUseWorktree} - onOrchChatChange={setOrchChat} + onOrchChatChange={changeOrchChat} + onWorkflowChange={changeWorkflow} + onEjectWorkflow={ejectWorkflow} />
@@ -234,7 +288,11 @@ export default function NewTaskDialog({ /> } placeholder={ - orchChat ? "What should the orchestrator coordinate?" : "What should the agent do?" + selectedWorkflow + ? `What should the ${selectedWorkflow.name} pipeline work on?` + : orchChat + ? "What should the orchestrator coordinate?" + : "What should the agent do?" } />
@@ -292,7 +350,7 @@ export default function NewTaskDialog({ onClick={() => composerRef.current?.submit()} disabled={!prompt.trim() || !project} > - {orchChat ? "Start orchestrator" : "Start task"} + {selectedWorkflow ? "Start workflow" : orchChat ? "Start orchestrator" : "Start task"}
From e709b77dda5e0b583c3c69eebfed5d1a7c3edccf Mon Sep 17 00:00:00 2001 From: Alexandr Shelevalnyk Date: Wed, 29 Jul 2026 20:27:11 +0200 Subject: [PATCH 4/8] feat(workflows): polish workflow execution UX --- .warpforge/workflows/review-loop.yaml | 67 +++ crates/warpforge-protocol/src/lib.rs | 73 +++ desktop/src/components/ChatComposer.test.tsx | 6 + desktop/src/components/ChatComposer.tsx | 4 +- desktop/src/components/ChatTranscript.tsx | 1 + desktop/src/components/Composer.tsx | 41 +- .../src/components/TaskAgentSwitcher.test.tsx | 43 ++ desktop/src/components/TaskAgentSwitcher.tsx | 41 +- .../src/components/WorkflowControls.test.tsx | 64 ++- desktop/src/components/WorkflowControls.tsx | 158 ++++-- desktop/src/components/WorkflowEventLine.tsx | 108 ++++ desktop/src/lib/taskGroups.test.ts | 16 + desktop/src/lib/taskGroups.ts | 2 + desktop/src/lib/workflow.ts | 18 + desktop/src/protocol.ts | 28 +- desktop/src/views/Board.tsx | 4 +- desktop/src/views/MissionControl.test.ts | 59 +++ desktop/src/views/MissionControl.tsx | 8 + desktop/src/views/TaskDetail.tsx | 2 +- .../src/views/task-detail/SubtasksRail.tsx | 35 +- src/daemon/acp.rs | 29 ++ src/daemon/actor.rs | 478 ++++++++++++++---- src/daemon/mod.rs | 194 ++++++- src/daemon/server.rs | 8 +- src/daemon/workflow.rs | 9 + src/workflows/plan-review-loop.yaml | 86 +++- src/workflows/review-loop.yaml | 82 ++- 27 files changed, 1449 insertions(+), 215 deletions(-) create mode 100644 .warpforge/workflows/review-loop.yaml create mode 100644 desktop/src/components/WorkflowEventLine.tsx create mode 100644 desktop/src/lib/workflow.ts diff --git a/.warpforge/workflows/review-loop.yaml b/.warpforge/workflows/review-loop.yaml new file mode 100644 index 0000000..7385e87 --- /dev/null +++ b/.warpforge/workflows/review-loop.yaml @@ -0,0 +1,67 @@ +# Built-in workflow: implement → review ⇄ fix. +# +# This workflow ALWAYS starts with implementation. Omitting `implement` only +# selects Warpforge's built-in implementation settings/prompt; it never removes +# this fixed stage. Copy the file into /.warpforge/workflows/ (or use +# "Copy to project" in the New Task dialog) to customize it. +version: 1 +name: Implement + review loop +description: Implement the task, then loop reviews and fixes until reviewers approve. + +# Uncomment agent/model to pin this stage. When omitted, they fall back to the +# lead agent/model picked in the New Task dialog. +implement: + # agent: claude + # model: claude-opus-5 + prompt: | + You are the implementation stage of a workflow pipeline. Implement the task + completely: write the code, keep the change focused, and verify your work + with builds/tests where feasible. Your final message must summarize what + you did because it is passed to the reviewers. + + ## Task + {{task_prompt}} + +review: + max_rounds: 2 # review ⇄ fix iterations before asking you what to do + on_limit: ask # ask | finish — behaviour when the limit is hit + context: [prompt, implementer_summary, diff] + reviewers: + # Each reviewer may use a different agent/model. Omitted values use Lead. + - agent: codex + model: gpt-5.6-sol + # focus: correctness and edge cases + prompt: | + You are a code reviewer in workflow round {{round}}/{{max_rounds}}. + Review the implementation against the task. Do not edit files. Verify + claims against the diff and report only concrete, actionable problems. + + ## Task + {{task_prompt}} + + ## Implementer's summary + {{implementer_summary}} + + ## Working-copy diff + {{diff}} + +fix: + # Uncomment to override the implementation stage. Omitted values inherit it. + # agent: claude + # model: claude-opus-5 + prompt: | + You are the repair stage of workflow round {{round}}/{{max_rounds}}. + Address every reviewer finding: fix it or, if it is factually wrong, + explain why in your final summary. Do not change unrelated code. Verify the + result where feasible and summarize what changed for the next review. + + ## Task + {{task_prompt}} + + ## Findings to address + {{findings}} + + ## Current working-copy diff + {{diff}} + +# Warpforge appends the need_user_input/verdict JSON protocols to these prompts. diff --git a/crates/warpforge-protocol/src/lib.rs b/crates/warpforge-protocol/src/lib.rs index 245d7bf..91f9e96 100644 --- a/crates/warpforge-protocol/src/lib.rs +++ b/crates/warpforge-protocol/src/lib.rs @@ -843,6 +843,38 @@ pub struct SessionUsageCost { pub currency: String, } +/// One agent session referenced by an inline workflow timeline event. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WorkflowEventAgent { + pub task_id: String, + pub label: String, + pub agent: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkflowEventKind { + WorkflowStarted, + StageStarted, + AgentOutput, + ReviewResult, + Status, + WorkflowFinished, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkflowEventTone { + Info, + Running, + Success, + Warning, + Error, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum SessionUpdate { @@ -860,6 +892,20 @@ pub enum SessionUpdate { AgentText { text: String, }, + /// A durable, independently rendered entry in a workflow parent's + /// Conversation timeline. Unlike streamed AgentText chunks these records + /// never coalesce, and agent references remain clickable after completion. + WorkflowEvent { + event: WorkflowEventKind, + title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + detail: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + stage: Option, + #[serde(default)] + agents: Vec, + tone: WorkflowEventTone, + }, AgentThought { text: String, }, @@ -1557,6 +1603,7 @@ mod tests { attachments: vec![], default_model: Some("opus".into()), config_overrides: Default::default(), + workflow: None, }, }; let json = serde_json::to_value(&req).unwrap(); @@ -1647,6 +1694,32 @@ mod tests { ); } + #[test] + fn workflow_event_keeps_agent_links_as_distinct_wire_records() { + let update = SessionUpdate::WorkflowEvent { + event: WorkflowEventKind::StageStarted, + title: "Implement started".into(), + detail: None, + stage: Some(WorkflowStage::Implement), + agents: vec![WorkflowEventAgent { + task_id: "t_impl".into(), + label: "implement".into(), + agent: "codex".into(), + model: Some("gpt-5.6-sol".into()), + }], + tone: WorkflowEventTone::Running, + }; + let value = serde_json::to_value(&update).unwrap(); + assert_eq!(value["kind"], "workflow_event"); + assert_eq!(value["event"], "stage_started"); + assert_eq!(value["stage"], "implement"); + assert_eq!(value["agents"][0]["taskId"], "t_impl"); + assert_eq!( + serde_json::from_value::(value).unwrap(), + update + ); + } + #[test] fn event_wire_shape() { let ev = Event::ServiceLog { diff --git a/desktop/src/components/ChatComposer.test.tsx b/desktop/src/components/ChatComposer.test.tsx index f61dca7..5f117f4 100644 --- a/desktop/src/components/ChatComposer.test.tsx +++ b/desktop/src/components/ChatComposer.test.tsx @@ -110,6 +110,12 @@ describe("ChatComposer — workflow parents", () => { ); }); + it("hard-stops a running workflow from the parent composer", async () => { + renderComposer(task(run())); + await userEvent.click(screen.getByRole("button", { name: /^stop$/i })); + expect(request).toHaveBeenCalledWith("task.cancel", { task_id: "t_1" }); + }); + it("re-enables input once the pipeline finishes", () => { renderComposer(task({ ...run(), stage: "done", waiting: null })); expect(screen.getByRole("textbox")).not.toBeDisabled(); diff --git a/desktop/src/components/ChatComposer.tsx b/desktop/src/components/ChatComposer.tsx index 2da5a54..edd6299 100644 --- a/desktop/src/components/ChatComposer.tsx +++ b/desktop/src/components/ChatComposer.tsx @@ -58,7 +58,9 @@ export const ChatComposer = memo( // whole pipeline, so the composer's stop button stays the terminal // "kill it" affordance next to WorkflowControls' soft pause. const onCancel = useCallback( - () => void daemon.request("task.cancel", { task_id: task.id }), + async () => { + await daemon.request("task.cancel", { task_id: task.id }); + }, [task.id], ); diff --git a/desktop/src/components/ChatTranscript.tsx b/desktop/src/components/ChatTranscript.tsx index cc953b2..113210f 100644 --- a/desktop/src/components/ChatTranscript.tsx +++ b/desktop/src/components/ChatTranscript.tsx @@ -182,6 +182,7 @@ const TranscriptRow = memo(function TranscriptRow({ resolveFilePath={resolveFilePath} onOpenFile={onOpenFile} onOpenFileDiff={onOpenFileDiff} + onOpenTask={onOpenTask} project={project} /> {messageText && ( diff --git a/desktop/src/components/Composer.tsx b/desktop/src/components/Composer.tsx index d346525..aefc5b9 100644 --- a/desktop/src/components/Composer.tsx +++ b/desktop/src/components/Composer.tsx @@ -1,4 +1,4 @@ -import { ImagePlus, Send, Square } from "lucide-react"; +import { ImagePlus, Loader2, Send, Square } from "lucide-react"; import { forwardRef, memo, @@ -62,7 +62,7 @@ export const Composer = forwardRef< ComposerHandle, { onSend: (submission: PromptSubmission) => void | Promise; - onCancel?: () => void; + onCancel?: () => void | Promise; commands?: CommandInfo[]; files?: ProjectFile[]; filesLoading?: boolean; @@ -102,6 +102,7 @@ export const Composer = forwardRef< const [diffs, setDiffs] = useState([]); const [images, setImages] = useState([]); const [sending, setSending] = useState(false); + const [stopping, setStopping] = useState(false); const [error, setError] = useState(null); const [dragging, setDragging] = useState(false); const textRef = useRef(null); @@ -234,6 +235,19 @@ export const Composer = forwardRef< } } + async function stop() { + if (!onCancel || stopping) return; + setStopping(true); + setError(null); + try { + await onCancel(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Task could not be stopped."); + } finally { + setStopping(false); + } + } + useLayoutEffect(() => { sendActionRef.current = () => void send(); }); @@ -405,9 +419,10 @@ export const Composer = forwardRef< {!hideSendButton && ( void stop()} + stopping={stopping} /> )}
@@ -422,25 +437,33 @@ const ComposerActionButton = memo(function ComposerActionButton({ disabled, sendActionRef, onStop, + stopping, }: { action: "send" | "stop"; disabled: boolean; sendActionRef: React.RefObject<() => void>; onStop?: () => void; + stopping: boolean; }) { - const stopping = action === "stop"; + const stopAction = action === "stop"; return ( ); }); diff --git a/desktop/src/components/TaskAgentSwitcher.test.tsx b/desktop/src/components/TaskAgentSwitcher.test.tsx index 4fa2898..be8dd52 100644 --- a/desktop/src/components/TaskAgentSwitcher.test.tsx +++ b/desktop/src/components/TaskAgentSwitcher.test.tsx @@ -60,4 +60,47 @@ describe("TaskAgentSwitcher", () => { expect(onOpenTask).not.toHaveBeenCalled(); }); + + it("labels a workflow root and its stage sessions explicitly", async () => { + const user = userEvent.setup(); + const root = { + ...task("root", "codex", "running"), + orchestrationGraph: { + goal: "Implement + review loop", + id: "root", + nodes: [ + { + agent: "codex", + id: "implement", + kind: "implement" as const, + status: "running" as const, + taskId: "child", + }, + ], + }, + workflowRun: { + maxRounds: 2, + round: 0, + stage: "implement" as const, + workflowId: "review-loop", + workflowName: "Implement + review loop", + }, + }; + const [tree] = buildTaskForest([root, task("child", "codex", "running", "root")]); + + render( + void>()} + />, + ); + + expect(screen.getByRole("button", { name: /current: workflow/i })).toHaveTextContent( + "Stages 1", + ); + await user.click(screen.getByRole("button", { name: /current: workflow/i })); + expect(await screen.findByRole("menuitem", { name: /implement · codex: running/i })) + .toBeInTheDocument(); + }); }); diff --git a/desktop/src/components/TaskAgentSwitcher.tsx b/desktop/src/components/TaskAgentSwitcher.tsx index ef5c126..6e456b3 100644 --- a/desktop/src/components/TaskAgentSwitcher.tsx +++ b/desktop/src/components/TaskAgentSwitcher.tsx @@ -28,7 +28,26 @@ export const TaskAgentSwitcher = memo(function TaskAgentSwitcher({ const members = useMemo(() => flattenTaskTree(tree), [tree]); const currentIndex = members.findIndex((member) => member.id === currentTaskId); const current = members[currentIndex] ?? tree.task; - const currentLabel = currentIndex === 0 ? "Lead" : agentDisplayName(current.agent); + const workflow = Boolean(tree.task.workflowRun); + const stageByTaskId = useMemo( + () => { + const result = new Map(); + for (const node of tree.task.orchestrationGraph?.nodes ?? []) { + if (node.taskId) result.set(node.taskId, node.id); + } + return result; + }, + [tree.task.orchestrationGraph?.nodes], + ); + const memberLabel = useCallback( + (member: (typeof members)[number], index: number) => { + if (index === 0) return workflow ? "Workflow" : "Lead"; + const stage = stageByTaskId.get(member.id); + return stage ? `${stage} · ${agentDisplayName(member.agent)}` : agentDisplayName(member.agent); + }, + [stageByTaskId, workflow], + ); + const currentLabel = memberLabel(current, currentIndex); const handleSelect = useCallback( (id: string) => { if (id !== currentTaskId) onOpenTask(id); @@ -48,12 +67,14 @@ export const TaskAgentSwitcher = memo(function TaskAgentSwitcher({ className="flex h-7 shrink-0 items-center gap-1.5 rounded px-2 text-xs text-muted-foreground hover:bg-secondary hover:text-foreground" > - Agents {members.length - 1} + {workflow ? "Stages" : "Agents"} {members.length - 1} · {currentIndex === 0 ? ( - Lead + + {workflow ? "Workflow" : "Lead"} + ) : ( - + {currentLabel} )} @@ -61,7 +82,8 @@ export const TaskAgentSwitcher = memo(function TaskAgentSwitcher({ {members.map((member, index) => { const selected = member.id === currentTaskId; - const label = index === 0 ? "Lead" : agentDisplayName(member.agent); + const label = memberLabel(member, index); + const stage = stageByTaskId.get(member.id); return ( {index === 0 ? ( - Lead + + {workflow ? "Workflow" : "Lead"} + ) : ( - + <> + {stage && {stage}} + + )} diff --git a/desktop/src/components/WorkflowControls.test.tsx b/desktop/src/components/WorkflowControls.test.tsx index aea241d..2fe51c5 100644 --- a/desktop/src/components/WorkflowControls.test.tsx +++ b/desktop/src/components/WorkflowControls.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -8,9 +8,11 @@ import { WorkflowControls } from "./WorkflowControls"; const workflowPause = vi.fn<(...args: unknown[]) => Promise>(async () => {}); const workflowResume = vi.fn<(...args: unknown[]) => Promise>(async () => {}); const workflowDecide = vi.fn<(...args: unknown[]) => Promise>(async () => {}); +const request = vi.fn<(...args: unknown[]) => Promise>(async () => ({})); vi.mock("../daemon", () => ({ daemon: { + request: (...args: unknown[]) => request(...(args as [])), workflowDecide: (...args: unknown[]) => workflowDecide(...(args as [])), workflowPause: (...args: unknown[]) => workflowPause(...(args as [])), workflowResume: (...args: unknown[]) => workflowResume(...(args as [])), @@ -42,7 +44,10 @@ function task(run: Partial): TaskInfo { } describe("WorkflowControls", () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + workflowDecide.mockImplementation(async () => {}); + }); it("shows the pipeline position and pauses a running stage", async () => { render(); @@ -69,19 +74,60 @@ describe("WorkflowControls", () => { />, ); expect(screen.getByText(/open findings: 2 high/)).toBeInTheDocument(); + expect(screen.getByText("Review limit reached")).toBeInTheDocument(); + expect(screen.getByText(/guidance typed below/i)).toBeInTheDocument(); // Pausing makes no sense while a decision is pending. expect(screen.queryByRole("button", { name: /pause/i })).not.toBeInTheDocument(); - await userEvent.click(screen.getByRole("button", { name: /2 more rounds/i })); + const oneRound = screen.getByRole("button", { name: /1 more round/i }); + const twoRounds = screen.getByRole("button", { name: /2 more rounds/i }); + const finish = screen.getByRole("button", { name: /finish for review/i }); + const stop = screen.getByRole("button", { name: /^stop$/i }); + expect(oneRound).toHaveClass("bg-primary"); + expect(twoRounds).toHaveClass("bg-primary"); + expect(finish).toHaveClass("bg-primary"); + expect(stop).toHaveClass("text-destructive", "bg-destructive/15"); + + await userEvent.click(oneRound); + expect(workflowDecide).toHaveBeenCalledWith("t_1", "extend", { rounds: 1 }); + + await userEvent.click(twoRounds); expect(workflowDecide).toHaveBeenCalledWith("t_1", "extend", { rounds: 2 }); - await userEvent.click(screen.getByRole("button", { name: /finish as is/i })); + await userEvent.click(finish); expect(workflowDecide).toHaveBeenCalledWith("t_1", "finish"); - await userEvent.click(screen.getByRole("button", { name: /stop/i })); + await userEvent.click(stop); expect(workflowDecide).toHaveBeenCalledWith("t_1", "stop"); }); + it("shows which limit action is in progress and locks competing decisions", async () => { + let finishRequest: (() => void) | undefined; + workflowDecide.mockImplementationOnce( + () => + new Promise((resolve) => { + finishRequest = resolve; + }), + ); + render( + , + ); + + await userEvent.click(screen.getByRole("button", { name: /finish for review/i })); + + expect(screen.getByRole("button", { name: /finishing/i })).toBeDisabled(); + expect(screen.getAllByRole("button").every((button) => button.hasAttribute("disabled"))).toBe( + true, + ); + + finishRequest?.(); + await waitFor(() => + expect(screen.getByRole("button", { name: /finish for review/i })).toBeEnabled(), + ); + }); + it("drops the controls once the pipeline is finished", () => { render(); expect(screen.queryByRole("button", { name: /pause/i })).not.toBeInTheDocument(); @@ -93,4 +139,12 @@ describe("WorkflowControls", () => { const { container } = render(); expect(container).toBeEmptyDOMElement(); }); + + it("hard-stops the parent workflow", async () => { + render(); + const stop = screen.getByRole("button", { name: /^stop$/i }); + expect(stop).toHaveClass("text-destructive", "bg-destructive/15"); + await userEvent.click(stop); + expect(request).toHaveBeenCalledWith("task.cancel", { task_id: "t_1" }); + }); }); diff --git a/desktop/src/components/WorkflowControls.tsx b/desktop/src/components/WorkflowControls.tsx index d71df60..6a9d78e 100644 --- a/desktop/src/components/WorkflowControls.tsx +++ b/desktop/src/components/WorkflowControls.tsx @@ -1,9 +1,10 @@ -import { CircleCheckBig, Pause, Play, Plus, Square } from "lucide-react"; +import { AlertTriangle, CircleCheckBig, Loader2, Pause, Play, Plus, Square } from "lucide-react"; import { memo, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +import { workflowStageLabel } from "@/lib/workflow"; import { daemon } from "../daemon"; import type { TaskInfo, WorkflowRunInfo } from "../protocol"; @@ -18,20 +19,21 @@ import type { TaskInfo, WorkflowRunInfo } from "../protocol"; */ export const WorkflowControls = memo(function WorkflowControls({ task }: { task: TaskInfo }) { const run = task.workflowRun; - const [busy, setBusy] = useState(false); + const [busyAction, setBusyAction] = useState(null); if (!run) return null; const waiting = run.waiting ?? null; const finished = run.stage === "done" || run.stage === "failed"; + const busy = busyAction !== null; const act = async (label: string, fn: () => Promise) => { - setBusy(true); + setBusyAction(label); try { await fn(); } catch (e) { toast.error(`Could not ${label}`, { description: String(e) }); } finally { - setBusy(false); + setBusyAction(null); } }; @@ -50,7 +52,12 @@ export const WorkflowControls = memo(function WorkflowControls({ task }: { task: disabled={busy} onClick={() => void act("resume", () => daemon.workflowResume(task.id))} > - Resume + {busyAction === "resume" ? ( + + ) : ( + + )} + {busyAction === "resume" ? "Resuming…" : "Resume"} ) : ( )} )} + {!finished && waiting?.kind !== "limit" && ( + + )} {waiting?.kind === "limit" && ( - + )} {waiting?.kind === "paused" && (

- Paused before the {stageLabel(run.stage)} stage. Type a message to resume with it as - guidance, or press Resume. + Paused before the {workflowStageLabel(run.stage)} stage. Type a message to resume with it + as guidance, or press Resume.

)} @@ -87,73 +124,101 @@ export const WorkflowControls = memo(function WorkflowControls({ task }: { task: function LimitDecision({ task, summary, - busy, + busyAction, act, }: { task: TaskInfo; summary: string; - busy: boolean; + busyAction: string | null; act: (label: string, fn: () => Promise) => Promise; }) { + const busy = busyAction !== null; return ( -
-

- Review rounds are used up{summary ? ` — ${summary}` : ""}. What next? -

-

- A message you type below is passed to the next fix attempt as guidance. -

-
+
+
+ +
+

Review limit reached

+

+ Reviewers still request changes{summary ? ` — ${summary}` : ""}. +

+

+ Continue the fix → review loop, finish with the current changes, or stop the workflow. +

+
+
+ +
-
+ +

+ Guidance typed below is used only when you continue with another round. +

+ ); } @@ -165,8 +230,8 @@ function StageIndicator({ run }: { run: WorkflowRunInfo }) { · {waiting?.kind === "paused" - ? `paused before ${stageLabel(run.stage)}` - : stageLabel(run.stage)} + ? `paused before ${workflowStageLabel(run.stage)}` + : workflowStageLabel(run.stage)} {run.round > 0 && run.stage !== "done" && run.stage !== "failed" && ( @@ -188,20 +253,3 @@ function StageIndicator({ run }: { run: WorkflowRunInfo }) { ); } - -export function stageLabel(stage: WorkflowRunInfo["stage"]): string { - switch (stage) { - case "plan": - return "planning"; - case "implement": - return "implementing"; - case "review": - return "reviewing"; - case "fix": - return "fixing"; - case "done": - return "done"; - case "failed": - return "failed"; - } -} diff --git a/desktop/src/components/WorkflowEventLine.tsx b/desktop/src/components/WorkflowEventLine.tsx new file mode 100644 index 0000000..9ae973a --- /dev/null +++ b/desktop/src/components/WorkflowEventLine.tsx @@ -0,0 +1,108 @@ +import { + CheckCircle2, + ChevronRight, + CircleDotDashed, + Workflow as WorkflowIcon, + XCircle, +} from "lucide-react"; + +import { cn } from "@/lib/utils"; +import type { SessionUpdate } from "@/protocol"; + +import { AgentBadge } from "./AgentBadge"; +import { CollapsibleMarkdown, Markdown } from "./Markdown"; + +type WorkflowEvent = Extract; + +export function WorkflowEventLine({ + compact, + onOpenTask, + update, +}: { + compact?: boolean; + onOpenTask?: (id: string) => void; + update: WorkflowEvent; +}) { + const Icon = + update.tone === "running" + ? CircleDotDashed + : update.tone === "success" + ? CheckCircle2 + : update.tone === "error" + ? XCircle + : WorkflowIcon; + const tone = { + error: "border-destructive/35 bg-destructive/[0.06] text-destructive", + info: "border-border bg-secondary/20 text-muted-foreground", + running: "border-primary/30 bg-primary/[0.06] text-primary", + success: "border-ok/30 bg-ok/[0.05] text-ok", + warning: "border-warn/35 bg-warn/[0.06] text-warn", + }[update.tone]; + const showAgentCards = update.event === "stage_started"; + + return ( +
+
+ + + {update.title} + + {!showAgentCards && + update.agents.map((agent) => ( + + ))} +
+ + {showAgentCards && update.agents.length > 0 && ( +
+ {update.agents.map((agent) => ( + + ))} +
+ )} + + {update.detail && + (compact ? ( + {update.detail} + ) : ( +
+ {update.detail} +
+ ))} +
+ ); +} diff --git a/desktop/src/lib/taskGroups.test.ts b/desktop/src/lib/taskGroups.test.ts index 6fae8ac..f919565 100644 --- a/desktop/src/lib/taskGroups.test.ts +++ b/desktop/src/lib/taskGroups.test.ts @@ -11,6 +11,7 @@ import { resolveGroupTaskId, setTaskGroupPinned, taskGroupStatus, + taskNeedsAttention, treeLane, treeMatches, } from "./taskGroups"; @@ -120,6 +121,21 @@ describe("task orchestration groups", () => { expect(forest).toStrictEqual([]); }); + it("treats a workflow waiting for a decision as attention while its task is idle", () => { + const workflow = task("workflow", "idle"); + workflow.workflowRun = { + maxRounds: 2, + round: 2, + stage: "review", + verdict: "request_changes", + waiting: { kind: "limit", question: "open findings: 1 medium" }, + workflowId: "review-loop", + workflowName: "Review loop", + }; + + expect(taskNeedsAttention(workflow)).toBeTruthy(); + }); + it("handles self-referencing parent (cycle to self)", () => { const forest = buildTaskForest([task("self-ref", "running", "self-ref")]); // Self-referencing task becomes a root (cycle broken) diff --git a/desktop/src/lib/taskGroups.ts b/desktop/src/lib/taskGroups.ts index 78b6d48..4983efc 100644 --- a/desktop/src/lib/taskGroups.ts +++ b/desktop/src/lib/taskGroups.ts @@ -63,7 +63,9 @@ export function taskLifecycle(task: TaskInfo, nowSeconds: number): TaskLifecycle } export function taskNeedsAttention(task: TaskInfo): boolean { + const waiting = task.workflowRun?.waiting ?? null; return ( + (!!waiting && waiting.kind !== "paused") || task.status === "needs_review" || task.status === "blocked" || task.status === "interrupted" ); } diff --git a/desktop/src/lib/workflow.ts b/desktop/src/lib/workflow.ts new file mode 100644 index 0000000..b9d8398 --- /dev/null +++ b/desktop/src/lib/workflow.ts @@ -0,0 +1,18 @@ +import type { WorkflowRunInfo } from "@/protocol"; + +export function workflowStageLabel(stage: WorkflowRunInfo["stage"]): string { + switch (stage) { + case "plan": + return "planning"; + case "implement": + return "implementing"; + case "review": + return "reviewing"; + case "fix": + return "fixing"; + case "done": + return "done"; + case "failed": + return "failed"; + } +} diff --git a/desktop/src/protocol.ts b/desktop/src/protocol.ts index 93c79cd..7b8716f 100644 --- a/desktop/src/protocol.ts +++ b/desktop/src/protocol.ts @@ -239,7 +239,7 @@ export interface OrchNodeInfo { result?: string | null; } -export type OrchNodeKind = "plan" | "implement" | "review" | "merge"; +export type OrchNodeKind = "plan" | "implement" | "review" | "fix" | "merge"; export type OrchNodeStatus = "pending" | "running" | "complete" | "failed" | "skipped"; @@ -339,10 +339,36 @@ export interface SessionUsageCost { currency: string; } +export interface WorkflowEventAgent { + taskId: string; + label: string; + agent: string; + model?: string | null; +} + +export type WorkflowEventKind = + | "workflow_started" + | "stage_started" + | "agent_output" + | "review_result" + | "status" + | "workflow_finished"; + +export type WorkflowEventTone = "info" | "running" | "success" | "warning" | "error"; + export type SessionUpdate = | { kind: "user_message"; text: string; attachments?: PromptAttachmentSummary[] } | { kind: "prompt_capabilities"; image: boolean; embedded_context: boolean } | { kind: "agent_text"; text: string } + | { + kind: "workflow_event"; + event: WorkflowEventKind; + title: string; + detail?: string | null; + stage?: WorkflowStage | null; + agents: WorkflowEventAgent[]; + tone: WorkflowEventTone; + } | { kind: "agent_thought"; text: string } | { kind: "tool_call"; diff --git a/desktop/src/views/Board.tsx b/desktop/src/views/Board.tsx index 3fc9177..a3ad8b4 100644 --- a/desktop/src/views/Board.tsx +++ b/desktop/src/views/Board.tsx @@ -27,7 +27,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { stageLabel } from "@/components/WorkflowControls"; +import { workflowStageLabel } from "@/lib/workflow"; import { elapsed } from "@/lib/status"; import type { BoardLifecycleFilter, TaskTree } from "@/lib/taskGroups"; import { @@ -729,7 +729,7 @@ function WorkflowBadge({ task }: { task: TaskInfo }) { ? "needs answer" : waiting?.kind === "paused" ? "paused" - : stageLabel(run.stage); + : workflowStageLabel(run.stage); return ( 0 ? ` — round ${run.round}/${run.maxRounds}` : ""}`} diff --git a/desktop/src/views/MissionControl.test.ts b/desktop/src/views/MissionControl.test.ts index 84e6285..a71bd07 100644 --- a/desktop/src/views/MissionControl.test.ts +++ b/desktop/src/views/MissionControl.test.ts @@ -146,6 +146,65 @@ describe("agent text streaming", () => { }); }); +describe("workflow conversation events", () => { + it("renders a persistent inline agent card and opens its child session", async () => { + const user = userEvent.setup(); + const onOpenTask = vi.fn<(id: string) => void>(); + render( + createElement(StreamLine, { + onOpenTask, + update: { + agents: [ + { + agent: "codex", + label: "implement", + model: "gpt-5.6-sol", + taskId: "t_impl", + }, + ], + event: "stage_started", + kind: "workflow_event", + stage: "implement", + title: "Implement started", + tone: "running", + }, + }), + ); + + expect(screen.getByText("Implement started")).toBeInTheDocument(); + expect(screen.getByText("gpt-5.6-sol")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Open implement agent session" })); + expect(onOpenTask).toHaveBeenCalledWith("t_impl"); + }); + + it("renders an agent summary as the next independent history entry", () => { + render( + createElement(StreamLine, { + update: { + agents: [ + { + agent: "codex", + label: "implement", + taskId: "t_impl", + }, + ], + detail: "Implemented the parser and ran **all tests**.", + event: "agent_output", + kind: "workflow_event", + stage: "implement", + title: "Implement completed", + tone: "success", + }, + }), + ); + + expect(screen.getByText("Implement completed")).toBeInTheDocument(); + expect(screen.getByText(/Implemented the parser/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open implement agent session" })) + .toBeInTheDocument(); + }); +}); + describe("incremental coalescing", () => { const stream: SessionUpdate[] = [ { kind: "user_message", text: "hi" }, diff --git a/desktop/src/views/MissionControl.tsx b/desktop/src/views/MissionControl.tsx index efe2fa0..77bf2f6 100644 --- a/desktop/src/views/MissionControl.tsx +++ b/desktop/src/views/MissionControl.tsx @@ -58,6 +58,7 @@ import { BufferedMarkdown, CollapsibleMarkdown, Markdown } from "../components/M import { StatusBadge } from "../components/StatusBadge"; import { TaskAgentSwitcher } from "../components/TaskAgentSwitcher"; import { ThinkingBlock } from "../components/ThinkingBlock"; +import { WorkflowEventLine } from "../components/WorkflowEventLine"; import type { DaemonState } from "../daemon"; import { daemon } from "../daemon"; import type { CommandInfo, EditHunk, ProjectFile, SessionUpdate, TaskInfo } from "../protocol"; @@ -703,6 +704,7 @@ export function StreamLine({ resolveFilePath, onOpenFile, onOpenFileDiff, + onOpenTask, project, thinkingActive, textStreaming, @@ -716,6 +718,8 @@ export function StreamLine({ resolveFilePath?: FileLinkResolver; onOpenFile?: (path: string) => void; onOpenFileDiff?: (path: string, hunks?: EditHunk[]) => void; + /** Opens a workflow stage/reviewer child from an inline timeline card. */ + onOpenTask?: (id: string) => void; /** Project root label retained after stripping the machine-specific prefix. */ project?: string; /** True only for the thought block currently receiving streamed deltas. */ @@ -779,6 +783,10 @@ export function StreamLine({ {update.text} ); + case "workflow_event": + return ( + + ); case "agent_thought": return compact ? ( Loading changes…

) ) : rightPanel === "subtasks" ? ( - + ) : ( +function SubtaskRow({ + node, + onOpenTask, +}: { + node: OrchNodeInfo; + onOpenTask: (id: string) => void; +}) { + const content = ( + <> {node.kind} {node.taskId && ( {node.taskId} )} + + ); + return node.taskId ? ( + + ) : ( +
+ {content}
); } -export function SubtasksRail({ task }: { task: TaskInfo }) { +export function SubtasksRail({ + onOpenTask, + task, +}: { + onOpenTask: (id: string) => void; + task: TaskInfo; +}) { const nodes = task.orchestrationGraph?.nodes ?? []; const graph = task.orchestrationGraph; if (!graph) { @@ -38,7 +63,7 @@ export function SubtasksRail({ task }: { task: TaskInfo }) {
{nodes.map((node) => ( - + ))}
diff --git a/src/daemon/acp.rs b/src/daemon/acp.rs index c6733c6..286c82e 100644 --- a/src/daemon/acp.rs +++ b/src/daemon/acp.rs @@ -98,6 +98,7 @@ pub enum AcpCommand { #[derive(Clone)] pub struct AcpHandle { cmd_tx: mpsc::UnboundedSender, + exit_rx: watch::Receiver, image_capability: Arc, process: Arc, run_id: u64, @@ -128,6 +129,29 @@ impl AcpHandle { let _ = self.cmd_tx.send(AcpCommand::Cancel); } + /// Request cancellation and wait until the process monitor has reaped the + /// ACP child. Callers can use this as the acknowledgement boundary for a + /// hard-stop operation. + pub async fn cancel_and_wait(&self) -> Result<(), String> { + self.cancel(); + self.wait_for_exit().await + } + + /// Wait for the process monitor's post-`child.wait()` notification. + pub async fn wait_for_exit(&self) -> Result<(), String> { + let mut exit_rx = self.exit_rx.clone(); + loop { + if matches!(*exit_rx.borrow(), ChildState::Exited(_)) { + return Ok(()); + } + if exit_rx.changed().await.is_err() { + return Err( + "ACP process monitor closed before confirming process termination".to_string(), + ); + } + } + } + pub fn run_id(&self) -> u64 { self.run_id } @@ -759,10 +783,12 @@ pub fn spawn_acp_session( let driver_stderr_capture = Arc::clone(&stderr_capture); let driver_default_model = default_model.clone(); let driver_config_overrides = config_overrides; + let driver_exit_state = exit_rx.clone(); tokio::spawn(async move { const INITIALIZE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); const RPC_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); let agent_name = sanitize_stderr(command_for_err.trim().as_bytes()); + let exit_rx = driver_exit_state; let mut driver_exit_rx = exit_rx.clone(); let init = match tokio::time::timeout( @@ -1182,6 +1208,7 @@ pub fn spawn_acp_session( Ok(AcpHandle { cmd_tx, + exit_rx, image_capability, process, run_id, @@ -2142,9 +2169,11 @@ mod tests { #[test] fn handle_enforces_negotiated_image_capability() { let (tx, mut rx) = mpsc::unbounded_channel(); + let (_exit_tx, exit_rx) = watch::channel(ChildState::Running); let capability = Arc::new(AtomicU8::new(1)); let handle = AcpHandle { cmd_tx: tx, + exit_rx, image_capability: Arc::clone(&capability), process: test_process_guard(), run_id: 1, diff --git a/src/daemon/actor.rs b/src/daemon/actor.rs index 87a8aad..9903052 100644 --- a/src/daemon/actor.rs +++ b/src/daemon/actor.rs @@ -149,7 +149,8 @@ fn is_acp_replay_update(update: &wire::SessionUpdate) -> bool { match update { wire::SessionUpdate::UserMessage { .. } | wire::SessionUpdate::PermissionResolved { .. } - | wire::SessionUpdate::PromptCapabilities { .. } => false, + | wire::SessionUpdate::PromptCapabilities { .. } + | wire::SessionUpdate::WorkflowEvent { .. } => false, wire::SessionUpdate::AgentText { text } => { text != "Reconnecting to the saved agent session…" && !text.starts_with("⚠ No live agent session") @@ -618,6 +619,7 @@ pub enum Command { }, CancelTask { id: String, + reply: oneshot::Sender>, }, /// Archive a task (set status to Done, hide from live views). ArchiveTask { @@ -1057,6 +1059,19 @@ impl DaemonHandle { .await; } + /// Hard-stop a task and wait until the actor has cancelled its session. + /// For workflow parents this also stops every active stage child. + pub async fn cancel_task(&self, id: &str) -> Result<(), String> { + let (tx, rx) = oneshot::channel(); + self.send(Command::CancelTask { + id: id.to_string(), + reply: tx, + }) + .await; + rx.await + .map_err(|_| "daemon stopped before the task was cancelled".to_string())? + } + pub async fn set_task_title(&self, id: &str, title: &str) { self.send(Command::SetTaskTitle { id: id.to_string(), @@ -2777,14 +2792,15 @@ impl Daemon { } } } - Command::CancelTask { id } => { - if self.workflow_is_active(&id) { + Command::CancelTask { id, reply } => { + let result = if self.workflow_is_active(&id) { // Stopping a workflow parent stops the whole pipeline. - self.workflow_finalize(&id, WorkflowOutcome::Stopped).await; + self.workflow_finalize(&id, WorkflowOutcome::Stopped).await } else { - if let Some(handle) = self.sessions.remove(&id) { - handle.cancel(); - } + let stop_result = match self.sessions.remove(&id) { + Some(handle) => handle.cancel_and_wait().await, + None => Ok(()), + }; self.pending_permissions.cleanup_task(&id); if let Some(task) = self.tasks.get_mut(&id) { task.set_status(TaskStatus::Idle); @@ -2794,13 +2810,15 @@ impl Daemon { } // Cancelling a stage child mid-run fails that stage. self.workflow_child_gone(&id).await; - } + stop_result + }; + let _ = reply.send(result); } Command::ArchiveTask { id } => { // Archiving a live workflow parent stops the pipeline first so // no orphaned stage sessions keep running behind a Done task. if self.workflow_is_active(&id) { - self.workflow_finalize(&id, WorkflowOutcome::Stopped).await; + let _ = self.workflow_finalize(&id, WorkflowOutcome::Stopped).await; } // Collect children that reference this task as parent so we // can archive them together with the leader. @@ -2831,7 +2849,7 @@ impl Daemon { } Command::DeleteTask { id } => { if self.workflow_is_active(&id) { - self.workflow_finalize(&id, WorkflowOutcome::Stopped).await; + let _ = self.workflow_finalize(&id, WorkflowOutcome::Stopped).await; } if self.workflow_runs.remove(&id).is_some() { if let Some(store) = &self.store { @@ -3783,6 +3801,13 @@ impl Daemon { async fn handle_acp_update(&mut self, task_id: String, update: AcpUpdate) { match update { AcpUpdate::SessionStarted { session_id } => { + // A hard stop removes the handle before awaiting process exit. + // Ignore an initialize reply that was already queued behind + // that stop; otherwise it would resurrect the cancelled child + // task as Running after task.cancel was acknowledged. + if !self.sessions.contains_key(&task_id) { + return; + } if let Some(task) = self.tasks.get_mut(&task_id) { task.attach_session(session_id); let updated = task.clone(); @@ -3895,6 +3920,7 @@ impl Daemon { // A clean turn end completes the node; a "disconnected" stop is // the agent process dying, which we treat as a failure. let success = stop_reason != "disconnected"; + let workflow_child = self.workflow_child_of(&task_id).is_some(); let update = wire::SessionUpdate::TurnEnded { stop_reason }; if self.should_skip_resume_replay(&task_id, &update) { return; @@ -3903,22 +3929,29 @@ impl Daemon { // Turn over: only NeedsReview if there are actually changes to // review; a pure Q&A turn goes Idle (waiting for the next // message) instead of falsely demanding a review. - if let Some(task) = self.tasks.get_mut(&task_id) { - if task.status == TaskStatus::Running { - let next = if task.files_changed > 0 { - TaskStatus::NeedsReview - } else { - TaskStatus::Idle - }; - task.set_status(next); - let updated = task.clone(); - self.persist(&updated); - self.emit(Event::TaskUpdated(updated)); + // + // Workflow children have different semantics: their output is + // consumed by the pipeline, so the workflow handler below owns + // their terminal/waiting status. File edits made by an + // implement/fix stage must not leak into NeedsReview here. + if !workflow_child { + if let Some(task) = self.tasks.get_mut(&task_id) { + if task.status == TaskStatus::Running { + let next = if task.files_changed > 0 { + TaskStatus::NeedsReview + } else { + TaskStatus::Idle + }; + task.set_status(next); + let updated = task.clone(); + self.persist(&updated); + self.emit(Event::TaskUpdated(updated)); + } } } let output = self.collect_agent_text(&task_id); self.notify_orch_finished(&task_id, success, output.clone()); - if self.workflow_child_of(&task_id).is_some() { + if workflow_child { // A workflow stage finished — advance the pipeline. Parse // only the latest turn's text: answered questions and // superseded verdicts from earlier turns must not count. @@ -4332,19 +4365,86 @@ impl Daemon { .map(|run| run.parent_id.clone()) } - /// Append a narration line to the parent's transcript. The parent has no - /// agent session — these synthetic entries *are* its timeline. - fn workflow_timeline(&self, parent_id: &str, text: impl Into) { + /// Append one durable, independently rendered workflow entry to the + /// parent's Conversation. Structured events deliberately do not use + /// AgentText: transport coalescing is correct for streamed agent chunks, + /// but would glue unrelated workflow transitions into one Markdown blob. + #[allow(clippy::too_many_arguments)] + fn workflow_event( + &self, + parent_id: &str, + event: wire::WorkflowEventKind, + title: impl Into, + detail: Option, + stage: Option, + agents: Vec, + tone: wire::WorkflowEventTone, + ) { self.emit_session( parent_id, - wire::SessionUpdate::AgentText { text: text.into() }, + wire::SessionUpdate::WorkflowEvent { + event, + title: title.into(), + detail, + stage: stage.map(StageKind::wire), + agents, + tone, + }, ); } + /// Convenience wrapper for transitions that do not reference a particular + /// agent. Split the first paragraph into the card title and keep the rest + /// as Markdown detail. + fn workflow_timeline(&self, parent_id: &str, text: impl Into) { + let text = text.into(); + let text = text.trim(); + let (heading, detail) = text + .split_once("\n\n") + .map(|(heading, detail)| (heading, Some(detail.trim().to_string()))) + .unwrap_or((text, None)); + let title = heading.trim_start_matches('#').trim().replace("**", ""); + let lower = text.to_ascii_lowercase(); + let tone = if lower.contains("failed") || lower.contains("stopped") { + wire::WorkflowEventTone::Error + } else if lower.contains("changes requested") + || lower.contains("limit reached") + || lower.contains("needs your input") + { + wire::WorkflowEventTone::Warning + } else if lower.contains("approved") || lower.contains("finished") { + wire::WorkflowEventTone::Success + } else { + wire::WorkflowEventTone::Info + }; + let event = if lower.starts_with("workflow") + && (lower.contains("finished") || lower.contains("failed") || lower.contains("stopped")) + { + wire::WorkflowEventKind::WorkflowFinished + } else { + wire::WorkflowEventKind::Status + }; + self.workflow_event(parent_id, event, title, detail, None, Vec::new(), tone); + } + /// Sync the parent task's `workflow_run` + `orchestration_graph` /// projections from the run, persist, and broadcast; also persist the run. fn workflow_sync(&mut self, run: &WorkflowRun) { if let Some(task) = self.tasks.get_mut(&run.parent_id) { + // The coarse task status describes whether work is executing, while + // workflow_run.waiting carries the precise barrier reason. + let active_status = match run.state { + RunState::Running { .. } => Some(TaskStatus::Running), + RunState::AwaitingReply { .. } + | RunState::AwaitingLimitDecision + | RunState::Paused { .. } => Some(TaskStatus::Idle), + RunState::Done | RunState::Failed => None, + }; + if let Some(status) = active_status { + if task.status != status { + task.set_status(status); + } + } task.workflow_run = Some(run.wire_info()); task.orchestration_graph = Some(run.graph_info()); task.updated_at = super::task::now_secs(); @@ -4359,6 +4459,22 @@ impl Daemon { } } + /// Workflow stage tasks are execution records, not independent changes to + /// review. Keep their lifecycle aligned with the stage state and emit the + /// update immediately so Board/Subtasks never retain a stale generic + /// turn-end status. + fn workflow_set_child_status(&mut self, child_id: &str, status: TaskStatus) { + if let Some(task) = self.tasks.get_mut(child_id) { + if task.status == status { + return; + } + task.set_status(status); + let updated = task.clone(); + self.persist(&updated); + self.emit(Event::TaskUpdated(updated)); + } + } + /// `CreateWorkflowTask`: validate the workflow, create the parent task /// (without an agent session), and start the first stage. #[allow(clippy::too_many_arguments)] @@ -4417,14 +4533,18 @@ impl Daemon { resolved_model, attachments, ); - self.workflow_timeline( + self.workflow_event( &parent_id, - format!( - "Workflow **{}** started (stages: {}; up to {} review rounds).", - run.spec.name, + wire::WorkflowEventKind::WorkflowStarted, + format!("Workflow started: {}", run.spec.name), + Some(format!( + "**Stages:** {} \n**Review limit:** {} round(s)", run.spec.stage_summary().join(" → "), run.effective_max_rounds(), - ), + )), + None, + Vec::new(), + wire::WorkflowEventTone::Info, ); let first = run.first_stage(); self.workflow_runs.insert(parent_id.clone(), run); @@ -4494,6 +4614,7 @@ impl Daemon { run.review_collected.clear(); run.reasked.clear(); let round_label = format!("round {}/{}", run.round, run.effective_max_rounds()); + let mut event_agents = Vec::with_capacity(run.spec.review.reviewers.len()); for index in 0..run.spec.review.reviewers.len() { let (agent, model) = run.stage_agent(stage, Some(index)); let prompt = workflow::build_reviewer_prompt(&run.spec, index, &ctx); @@ -4502,7 +4623,7 @@ impl Daemon { &run.project, parent_id, &agent, - model, + model.clone(), prompt, worktree.clone(), format!("review · {parent_title}"), @@ -4511,13 +4632,21 @@ impl Daemon { run.review_pending.insert(child_id.clone(), index); run.active_children.insert(child_id.clone(), stage); run.record_stage(stage, &child_id, &agent, format!("{label}, {round_label}")); + event_agents.push(wire::WorkflowEventAgent { + task_id: child_id, + label, + agent, + model, + }); } - self.workflow_timeline( + self.workflow_event( parent_id, - format!( - "Review {round_label} started — {} reviewer(s) running.", - run.review_pending.len() - ), + wire::WorkflowEventKind::StageStarted, + format!("Review {round_label} started"), + Some(format!("{} reviewer(s) running.", run.review_pending.len())), + Some(stage), + event_agents, + wire::WorkflowEventTone::Running, ); } _ => { @@ -4543,14 +4672,20 @@ impl Daemon { StageKind::Fix => format!("{} (round {})", stage.label(), run.round), _ => stage.label().to_string(), }; - run.record_stage(stage, &child_id, &agent, label); - self.workflow_timeline( + run.record_stage(stage, &child_id, &agent, label.clone()); + self.workflow_event( parent_id, - format!( - "Stage **{}** started — {agent}{}.", - stage.label(), - model.map(|m| format!(" ({m})")).unwrap_or_default() - ), + wire::WorkflowEventKind::StageStarted, + format!("{} started", stage.title()), + None, + Some(stage), + vec![wire::WorkflowEventAgent { + task_id: child_id, + label, + agent, + model, + }], + wire::WorkflowEventTone::Running, ); } } @@ -4639,12 +4774,26 @@ impl Daemon { child: child_id.to_string(), question: question.clone(), }; - self.workflow_timeline( + self.workflow_set_child_status(child_id, TaskStatus::Idle); + let event_agent = run + .history + .iter() + .rev() + .find(|record| record.task_id == child_id) + .map(|record| wire::WorkflowEventAgent { + task_id: record.task_id.clone(), + label: record.label.clone(), + agent: record.agent.clone(), + model: run.stage_agent(stage, None).1, + }); + self.workflow_event( &parent_id, - format!( - "Stage **{}** needs your input:\n\n> {question}\n\nAnswer in this chat to continue.", - stage.label() - ), + wire::WorkflowEventKind::AgentOutput, + format!("{} needs your input", stage.title()), + Some(workflow::clip_summary(&output)), + Some(stage), + event_agent.into_iter().collect(), + wire::WorkflowEventTone::Warning, ); self.workflow_sync(&run); self.workflow_runs.insert(parent_id.clone(), run); @@ -4652,13 +4801,30 @@ impl Daemon { StageSignal::Output => { run.active_children.remove(child_id); run.set_record_status(child_id, wire::OrchNodeStatus::Complete); + self.workflow_set_child_status(child_id, TaskStatus::Done); + let event_agent = run + .history + .iter() + .rev() + .find(|record| record.task_id == child_id) + .map(|record| wire::WorkflowEventAgent { + task_id: record.task_id.clone(), + label: record.label.clone(), + agent: record.agent.clone(), + model: run.stage_agent(stage, None).1, + }); match stage { - StageKind::Plan => run.plan_output = Some(output), - _ => run.last_summary = Some(output), + StageKind::Plan => run.plan_output = Some(output.clone()), + _ => run.last_summary = Some(output.clone()), } - self.workflow_timeline( + self.workflow_event( &parent_id, - format!("Stage **{}** finished.", stage.label()), + wire::WorkflowEventKind::AgentOutput, + format!("{} completed", stage.title()), + Some(workflow::clip_summary(&output)), + Some(stage), + event_agent.into_iter().collect(), + wire::WorkflowEventTone::Success, ); let next = stage.successor().unwrap_or(StageKind::Review); self.workflow_runs.insert(parent_id.clone(), run); @@ -4686,18 +4852,35 @@ impl Daemon { let label = index .map(|i| run.reviewer_label(i)) .unwrap_or_else(|| "reviewer".to_string()); - self.workflow_timeline( + let event_agent = run + .history + .iter() + .rev() + .find(|record| record.task_id == child_id) + .map(|record| wire::WorkflowEventAgent { + task_id: record.task_id.clone(), + label: record.label.clone(), + agent: record.agent.clone(), + model: index.and_then(|i| run.stage_agent(StageKind::Review, Some(i)).1), + }); + self.workflow_event( parent_id, - format!("{label} failed — excluded from this round's verdict."), + wire::WorkflowEventKind::AgentOutput, + format!("{label} failed"), + Some("Excluded from this round's verdict.".to_string()), + Some(stage), + event_agent.into_iter().collect(), + wire::WorkflowEventTone::Error, ); if run.review_pending.is_empty() { if run.review_collected.is_empty() { self.workflow_runs.insert(parent_id.to_string(), run); - self.workflow_finalize( - parent_id, - WorkflowOutcome::Error("all reviewers failed".to_string()), - ) - .await; + let _ = self + .workflow_finalize( + parent_id, + WorkflowOutcome::Error("all reviewers failed".to_string()), + ) + .await; } else { self.workflow_merge_reviews(parent_id, run).await; } @@ -4712,12 +4895,33 @@ impl Daemon { .get(child_id) .and_then(|t| t.blocked_reason.clone()) .unwrap_or_else(|| "agent session ended unexpectedly".to_string()); - self.workflow_runs.insert(parent_id.to_string(), run); - self.workflow_finalize( + let event_agent = run + .history + .iter() + .rev() + .find(|record| record.task_id == child_id) + .map(|record| wire::WorkflowEventAgent { + task_id: record.task_id.clone(), + label: record.label.clone(), + agent: record.agent.clone(), + model: run.stage_agent(stage, None).1, + }); + self.workflow_event( parent_id, - WorkflowOutcome::Error(format!("stage {} failed: {reason}", stage.label())), - ) - .await; + wire::WorkflowEventKind::AgentOutput, + format!("{} failed", stage.title()), + Some(reason.clone()), + Some(stage), + event_agent.into_iter().collect(), + wire::WorkflowEventTone::Error, + ); + self.workflow_runs.insert(parent_id.to_string(), run); + let _ = self + .workflow_finalize( + parent_id, + WorkflowOutcome::Error(format!("stage {} failed: {reason}", stage.label())), + ) + .await; } /// One reviewer's turn ended: parse its verdict, re-ask once on garbage, @@ -4736,6 +4940,36 @@ impl Daemon { let label = run.reviewer_label(index); match workflow::parse_review_verdict(&output, &label) { Ok((verdict, findings)) => { + self.workflow_set_child_status(child_id, TaskStatus::Done); + let event_agent = run + .history + .iter() + .rev() + .find(|record| record.task_id == child_id) + .map(|record| wire::WorkflowEventAgent { + task_id: record.task_id.clone(), + label: record.label.clone(), + agent: record.agent.clone(), + model: run.stage_agent(StageKind::Review, Some(index)).1, + }); + self.workflow_event( + parent_id, + wire::WorkflowEventKind::ReviewResult, + format!( + "{label}: {}", + match verdict { + Verdict::Approve => "approved", + Verdict::RequestChanges => "changes requested", + }, + ), + Some(workflow::clip_summary(&output)), + Some(StageKind::Review), + event_agent.into_iter().collect(), + match verdict { + Verdict::Approve => wire::WorkflowEventTone::Success, + Verdict::RequestChanges => wire::WorkflowEventTone::Warning, + }, + ); run.review_pending.remove(child_id); run.active_children.remove(child_id); run.set_record_status(child_id, wire::OrchNodeStatus::Complete); @@ -4748,6 +4982,26 @@ impl Daemon { } } Err(reason) => { + let event_agent = run + .history + .iter() + .rev() + .find(|record| record.task_id == child_id) + .map(|record| wire::WorkflowEventAgent { + task_id: record.task_id.clone(), + label: record.label.clone(), + agent: record.agent.clone(), + model: run.stage_agent(StageKind::Review, Some(index)).1, + }); + self.workflow_event( + parent_id, + wire::WorkflowEventKind::AgentOutput, + format!("{label}: invalid review response"), + Some(workflow::clip_summary(&output)), + Some(StageKind::Review), + event_agent.into_iter().collect(), + wire::WorkflowEventTone::Warning, + ); let asked = run.reasked.entry(child_id.to_string()).or_insert(0); if *asked < workflow::MAX_VERDICT_REASKS { *asked += 1; @@ -4786,13 +5040,14 @@ impl Daemon { // Dead session: fall through to the failure path. } self.workflow_runs.insert(parent_id.to_string(), run); - self.workflow_finalize( - parent_id, - WorkflowOutcome::Error(format!( - "{label} returned no parseable verdict after a retry ({reason})" - )), - ) - .await; + let _ = self + .workflow_finalize( + parent_id, + WorkflowOutcome::Error(format!( + "{label} returned no parseable verdict after a retry ({reason})" + )), + ) + .await; } } } @@ -4813,7 +5068,8 @@ impl Daemon { ); run.open_findings.clear(); self.workflow_runs.insert(parent_id.to_string(), run); - self.workflow_finalize(parent_id, WorkflowOutcome::Success { limit_hit: false }) + let _ = self + .workflow_finalize(parent_id, WorkflowOutcome::Success { limit_hit: false }) .await; } Verdict::RequestChanges if to_fix.is_empty() => { @@ -4828,7 +5084,8 @@ impl Daemon { ); run.open_findings.clear(); self.workflow_runs.insert(parent_id.to_string(), run); - self.workflow_finalize(parent_id, WorkflowOutcome::Success { limit_hit: false }) + let _ = self + .workflow_finalize(parent_id, WorkflowOutcome::Success { limit_hit: false }) .await; } Verdict::RequestChanges => { @@ -4864,11 +5121,12 @@ impl Daemon { } crate::workflow_config::OnLimit::Finish => { self.workflow_runs.insert(parent_id.to_string(), run); - self.workflow_finalize( - parent_id, - WorkflowOutcome::Success { limit_hit: true }, - ) - .await; + let _ = self + .workflow_finalize( + parent_id, + WorkflowOutcome::Success { limit_hit: true }, + ) + .await; } } } @@ -4907,39 +5165,57 @@ impl Daemon { } } - /// Kill any still-running stage children (their sessions), Idle them. - fn workflow_stop_children(&mut self, run: &mut WorkflowRun) { + /// Kill any still-running stage children (their sessions), wait until + /// their processes have exited, then Idle them. + async fn workflow_stop_children(&mut self, run: &mut WorkflowRun) -> Result<(), String> { let children: Vec = run.active_children.keys().cloned().collect(); + let mut handles = Vec::new(); for child_id in children { if let Some(handle) = self.sessions.remove(&child_id) { handle.cancel(); + handles.push(handle); } self.pending_permissions.cleanup_task(&child_id); run.set_record_status(&child_id, wire::OrchNodeStatus::Skipped); if let Some(task) = self.tasks.get_mut(&child_id) { if task.status == TaskStatus::Running || task.status == TaskStatus::Queued { - task.set_status(TaskStatus::Idle); + task.set_status(TaskStatus::Interrupted); let updated = task.clone(); self.persist(&updated); self.emit(Event::TaskUpdated(updated)); } } } + // Signal every parallel reviewer before awaiting any one of them. + let mut stop_error = None; + for handle in handles { + if let Err(error) = handle.wait_for_exit().await { + stop_error.get_or_insert(error); + } + } run.active_children.clear(); run.review_pending.clear(); + match stop_error { + Some(error) => Err(error), + None => Ok(()), + } } /// End the pipeline: stop children, write the summary, set the parent's /// final status. - async fn workflow_finalize(&mut self, parent_id: &str, outcome: WorkflowOutcome) { + async fn workflow_finalize( + &mut self, + parent_id: &str, + outcome: WorkflowOutcome, + ) -> Result<(), String> { let Some(mut run) = self.workflow_runs.remove(parent_id) else { - return; + return Ok(()); }; if !run.is_active() { self.workflow_runs.insert(parent_id.to_string(), run); - return; + return Ok(()); } - self.workflow_stop_children(&mut run); + let stop_result = self.workflow_stop_children(&mut run).await; let mut summary = String::new(); let rounds_used = run.round; @@ -4991,6 +5267,7 @@ impl Daemon { } self.workflow_sync(&run); self.workflow_runs.insert(parent_id.to_string(), run); + stop_result } /// A stage child task was cancelled or deleted out from under the run. @@ -5161,10 +5438,20 @@ impl Daemon { match decision { wire::WorkflowDecision::Extend => { let granted = rounds.unwrap_or(1).clamp(1, workflow::MAX_EXTEND_ROUNDS); + let guidance = note.filter(|note| !note.trim().is_empty()); + if let Some(message) = guidance.as_ref() { + self.emit_session( + parent_id, + wire::SessionUpdate::UserMessage { + text: message.clone(), + attachments: vec![], + }, + ); + } { let run = self.workflow_runs.get_mut(parent_id).unwrap(); run.extra_rounds += granted; - run.pending_guidance = note.filter(|n| !n.trim().is_empty()); + run.pending_guidance = guidance; } self.workflow_timeline( parent_id, @@ -5176,12 +5463,12 @@ impl Daemon { wire::WorkflowDecision::Finish => { self.workflow_timeline(parent_id, "You chose to finish with the open findings."); self.workflow_finalize(parent_id, WorkflowOutcome::Success { limit_hit: true }) - .await; + .await?; Ok(()) } wire::WorkflowDecision::Stop => { self.workflow_finalize(parent_id, WorkflowOutcome::Stopped) - .await; + .await?; Ok(()) } } @@ -5226,11 +5513,18 @@ impl Daemon { ); } // The store normalizes Running → Interrupted on load; a live - // pipeline parent is Running again (its waiting state, if any, - // is carried by workflow_run). + // pipeline parent is restored according to whether a stage is + // executing or the runner is parked at a barrier. if let Some(task) = self.tasks.get_mut(&task_id) { task.blocked_reason = None; - task.set_status(TaskStatus::Running); + let status = match run.state { + RunState::Running { .. } => TaskStatus::Running, + RunState::AwaitingReply { .. } + | RunState::AwaitingLimitDecision + | RunState::Paused { .. } => TaskStatus::Idle, + RunState::Done | RunState::Failed => task.status.clone(), + }; + task.set_status(status); } } if let Some(task) = self.tasks.get_mut(&task_id) { diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index b9f6713..10b5637 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -327,7 +327,7 @@ mod tests { .await; let mut events = daemon.subscribe(); - daemon.send(Command::CancelTask { id: id.clone() }).await; + daemon.cancel_task(&id).await.expect("cancel accepted"); timeout(Duration::from_secs(1), async { loop { @@ -680,6 +680,108 @@ mod tests { assert!(graph.nodes.iter().all(|n| n.task_id.is_some())); assert_eq!(graph.nodes[0].kind, wire::OrchNodeKind::Implement); assert_eq!(graph.nodes[2].kind, wire::OrchNodeKind::Fix); + + // The parent conversation is a useful workflow narrative, not just a + // sequence of opaque/coalesced stage transitions. Agent cards and + // results remain independent, ordered history entries. + let snapshot = daemon.snapshot().await; + let workflow_events: Vec<_> = snapshot.session_history[&parent_id] + .iter() + .filter(|update| matches!(update, wire::SessionUpdate::WorkflowEvent { .. })) + .collect(); + assert!(matches!( + workflow_events.first(), + Some(wire::SessionUpdate::WorkflowEvent { + event: wire::WorkflowEventKind::WorkflowStarted, + .. + }) + )); + let implement_started = workflow_events + .iter() + .position(|update| { + matches!( + update, + wire::SessionUpdate::WorkflowEvent { + event: wire::WorkflowEventKind::StageStarted, + stage: Some(wire::WorkflowStage::Implement), + agents, + .. + } if agents.len() == 1 + ) + }) + .unwrap(); + let implement_summary = workflow_events + .iter() + .position(|update| { + matches!( + update, + wire::SessionUpdate::WorkflowEvent { + event: wire::WorkflowEventKind::AgentOutput, + detail: Some(detail), + .. + } if detail.contains("IMPL-DONE: implemented the change.") + ) + }) + .unwrap(); + let first_review = workflow_events + .iter() + .position(|update| { + matches!( + update, + wire::SessionUpdate::WorkflowEvent { + event: wire::WorkflowEventKind::StageStarted, + stage: Some(wire::WorkflowStage::Review), + .. + } + ) + }) + .unwrap(); + let finding = workflow_events + .iter() + .position(|update| { + matches!( + update, + wire::SessionUpdate::WorkflowEvent { + event: wire::WorkflowEventKind::ReviewResult, + detail: Some(detail), + .. + } if detail.contains("bug here") + ) + }) + .unwrap(); + let fix_summary = workflow_events + .iter() + .position(|update| { + matches!( + update, + wire::SessionUpdate::WorkflowEvent { + event: wire::WorkflowEventKind::AgentOutput, + detail: Some(detail), + .. + } if detail.contains("FIX-DONE: addressed the findings.") + ) + }) + .unwrap(); + assert!(implement_started < implement_summary); + assert!(implement_summary < first_review); + assert!(first_review < finding); + assert!(finding < fix_summary); + assert!(workflow_events.iter().any(|update| matches!( + update, + wire::SessionUpdate::WorkflowEvent { + event: wire::WorkflowEventKind::ReviewResult, + title, + .. + } if title.contains("approved") + ))); + assert!( + snapshot + .tasks + .iter() + .filter(|task| task.parent_task_id.as_deref() == Some(&parent_id)) + .all(|task| task.status == wire::TaskStatus::Done), + "consumed workflow stages should be terminal, not idle/needs-review" + ); } #[tokio::test] @@ -707,9 +809,17 @@ mod tests { .is_some_and(|w| w.kind == wire::WorkflowWaitKind::Question) }) .await; + assert_eq!(waiting.status, TaskStatus::Idle); let question = waiting.workflow_run.unwrap().waiting.unwrap(); assert_eq!(question.question.as_deref(), Some("Which database?")); assert_eq!(question.stage, Some(wire::WorkflowStage::Plan)); + let asking_child = daemon + .tasks() + .await + .into_iter() + .find(|task| task.parent_task_id.as_deref() == Some(&parent_id)) + .expect("asking plan stage"); + assert_eq!(asking_child.status, TaskStatus::Idle); let (tx, rx) = tokio::sync::oneshot::channel(); daemon @@ -750,13 +860,23 @@ mod tests { let mut events = daemon.subscribe(); let parent_id = create_workflow_task(&daemon, &lead).await; - wait_for_parent(&mut events, &parent_id, "limit decision", |t| { + let waiting = wait_for_parent(&mut events, &parent_id, "limit decision", |t| { t.workflow_run .as_ref() .and_then(|w| w.waiting.as_ref()) .is_some_and(|w| w.kind == wire::WorkflowWaitKind::Limit) }) .await; + assert_eq!(waiting.status, TaskStatus::Idle); + assert!( + daemon + .tasks() + .await + .iter() + .filter(|task| task.parent_task_id.as_deref() == Some(&parent_id)) + .all(|task| task.status == TaskStatus::Done), + "every stage has completed when the review-limit decision is shown" + ); // A pause is invalid while waiting on a decision. let (tx, rx) = tokio::sync::oneshot::channel(); @@ -820,13 +940,14 @@ mod tests { .await; rx.await.unwrap().expect("pause accepted while running"); - wait_for_parent(&mut events, &parent_id, "paused at barrier", |t| { + let paused = wait_for_parent(&mut events, &parent_id, "paused at barrier", |t| { t.workflow_run .as_ref() .and_then(|w| w.waiting.as_ref()) .is_some_and(|w| w.kind == wire::WorkflowWaitKind::Paused) }) .await; + assert_eq!(paused.status, TaskStatus::Idle); let (tx, rx) = tokio::sync::oneshot::channel(); daemon @@ -847,6 +968,71 @@ mod tests { assert_eq!(done.status, TaskStatus::NeedsReview); } + #[tokio::test] + async fn workflow_parent_cancel_stops_the_active_stage_before_acknowledging() { + use warpforge_protocol as wire; + let (dir, projects) = workflow_project("name: Cancel flow\n"); + let pid_path = dir.path().join("cancel.pid"); + let lead = format!( + "echo $$ > {}; exec {}", + pid_path.display(), + wf_agent(&dir, "cancel.state", "slow-impl") + ); + let daemon = Daemon::spawn( + projects, + Store::open_at(std::path::Path::new(":memory:")).ok(), + ); + let parent_id = create_workflow_task(&daemon, &lead).await; + + let pid = timeout(Duration::from_secs(2), async { + loop { + if let Ok(pid) = std::fs::read_to_string(&pid_path) { + let pid = pid.trim(); + if pid.parse::().is_ok() { + break pid.to_string(); + } + } + tokio::task::yield_now().await; + } + }) + .await + .expect("active stage should write its process id"); + + daemon + .cancel_task(&parent_id) + .await + .expect("workflow cancellation acknowledged"); + + let process_alive = tokio::process::Command::new("kill") + .args(["-0", &pid]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await + .is_ok_and(|status| status.success()); + assert!( + !process_alive, + "task.cancel acknowledged before ACP process {pid} exited" + ); + + let tasks = daemon.tasks().await; + let parent = tasks.iter().find(|task| task.id == parent_id).unwrap(); + assert_eq!(parent.status, TaskStatus::Interrupted); + assert_eq!( + parent.workflow_run.as_ref().map(|run| run.stage), + Some(wire::WorkflowStage::Failed) + ); + let child = tasks + .iter() + .find(|task| task.parent_task_id.as_deref() == Some(&parent_id)) + .expect("active workflow stage"); + assert_eq!(child.status, TaskStatus::Interrupted); + assert_eq!( + parent.orchestration_graph.as_ref().unwrap().nodes[0].status, + wire::OrchNodeStatus::Skipped + ); + } + /// A daemon restart mid-stage parks the pipeline at its last barrier as /// Paused; resume re-runs the interrupted stage and the run completes. #[tokio::test] @@ -889,7 +1075,7 @@ mod tests { }) .await .expect("restored run should be paused at the implement barrier"); - assert_eq!(restored.status, TaskStatus::Running); + assert_eq!(restored.status, TaskStatus::Idle); assert_eq!( restored.workflow_run.unwrap().stage, wire::WorkflowStage::Implement diff --git a/src/daemon/server.rs b/src/daemon/server.rs index 6d59b5b..53f6d4d 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -609,7 +609,13 @@ async fn dispatch( Ok(json!({ "text": text })) } TaskCancel { task_id } => { - handle.send(Command::CancelTask { id: task_id }).await; + handle + .cancel_task(&task_id) + .await + .map_err(|message| wire::RpcError { + code: wire::ErrorCode::Internal, + message, + })?; Ok(json!(null)) } TaskArchive { task_id } => { diff --git a/src/daemon/workflow.rs b/src/daemon/workflow.rs index 90c1c43..1eb5450 100644 --- a/src/daemon/workflow.rs +++ b/src/daemon/workflow.rs @@ -45,6 +45,15 @@ impl StageKind { } } + pub fn title(self) -> &'static str { + match self { + StageKind::Plan => "Plan", + StageKind::Implement => "Implement", + StageKind::Review => "Review", + StageKind::Fix => "Fix", + } + } + pub fn wire(self) -> wire::WorkflowStage { match self { StageKind::Plan => wire::WorkflowStage::Plan, diff --git a/src/workflows/plan-review-loop.yaml b/src/workflows/plan-review-loop.yaml index a87439f..d1796d2 100644 --- a/src/workflows/plan-review-loop.yaml +++ b/src/workflows/plan-review-loop.yaml @@ -1,17 +1,85 @@ # Built-in workflow: plan → implement → review ⇄ fix. # -# Copy this file into /.warpforge/workflows/ (or use "Copy to -# project" in the New Task dialog) to customize it. Every key except `name` -# is optional — omitted stages fall back to the lead agent picked in the -# dialog and to the built-in stage prompts. +# Every stage is explicit here so this file is both runnable and a complete +# customization example. Agent/model omissions use the lead agent selected in +# the New Task dialog. version: 1 -name: Plan + review loop -description: Plan first, then implement and loop reviews and fixes until approved. +name: Plan + implement + review loop +description: Plan first, implement the plan, then loop reviews and fixes until approved. -plan: {} # presence of this key enables the planning stage (a bare `plan:` works too) -# agent: claude -# model: claude-opus-5 +plan: + # agent: claude + # model: claude-opus-5 + prompt: | + You are the planning stage of a workflow pipeline. Explore the codebase as + needed and produce a concise implementation plan: files to touch, approach, + edge cases, and verification. Do not edit files. End with the complete plan + because it is passed verbatim to the implementer. + + ## Task + {{task_prompt}} + +implement: + # agent: codex + # model: gpt-5.6-sol + prompt: | + You are the implementation stage of a workflow pipeline. Implement the task + and approved plan completely: write the code, keep the change focused, and + verify your work with builds/tests where feasible. Your final message must + summarize what you did because it is passed to the reviewers. + + ## Task + {{task_prompt}} + + ## Approved plan + {{plan}} review: max_rounds: 2 # review ⇄ fix iterations before asking you what to do on_limit: ask # ask | finish — behaviour when the limit is hit + context: [prompt, plan, implementer_summary, diff] + reviewers: + - # agent: claude + # model: claude-opus-5 + # focus: correctness and edge cases + prompt: | + You are a code reviewer in workflow round {{round}}/{{max_rounds}}. + Review the implementation against the task and approved plan. Do not + edit files. Verify claims against the diff and report only concrete, + actionable problems. + + ## Task + {{task_prompt}} + + ## Approved plan + {{plan}} + + ## Implementer's summary + {{implementer_summary}} + + ## Working-copy diff + {{diff}} + +fix: + # Omitted agent/model inherit the implementation stage; uncomment to pin. + # agent: codex + # model: gpt-5.6-sol + prompt: | + You are the repair stage of workflow round {{round}}/{{max_rounds}}. + Address every reviewer finding: fix it or, if it is factually wrong, + explain why in your final summary. Do not change unrelated code. Verify the + result where feasible and summarize what changed for the next review. + + ## Task + {{task_prompt}} + + ## Approved plan + {{plan}} + + ## Findings to address + {{findings}} + + ## Current working-copy diff + {{diff}} + +# Warpforge appends the need_user_input/verdict JSON protocols to these prompts. diff --git a/src/workflows/review-loop.yaml b/src/workflows/review-loop.yaml index cc6e389..17bca9a 100644 --- a/src/workflows/review-loop.yaml +++ b/src/workflows/review-loop.yaml @@ -1,31 +1,67 @@ # Built-in workflow: implement → review ⇄ fix. # -# Copy this file into /.warpforge/workflows/ (or use "Copy to -# project" in the New Task dialog) to customize it. Every key except `name` -# is optional — omitted stages fall back to the lead agent picked in the -# dialog and to the built-in stage prompts. +# This workflow ALWAYS starts with implementation. Omitting `implement` only +# selects Warpforge's built-in implementation settings/prompt; it never removes +# this fixed stage. Copy the file into /.warpforge/workflows/ (or use +# "Copy to project" in the New Task dialog) to customize it. version: 1 -name: Review loop -description: Implement, then loop reviews and fixes until reviewers approve. +name: Implement + review loop +description: Implement the task, then loop reviews and fixes until reviewers approve. -# implement: # defaults to the lead agent from the dialog -# agent: claude -# model: claude-opus-5 -# prompt: | # custom stage prompt, supports {{task_prompt}}, {{plan}} -# ... +# Uncomment agent/model to pin this stage. When omitted, they fall back to the +# lead agent/model picked in the New Task dialog. +implement: + # agent: claude + # model: claude-opus-5 + prompt: | + You are the implementation stage of a workflow pipeline. Implement the task + completely: write the code, keep the change focused, and verify your work + with builds/tests where feasible. Your final message must summarize what + you did because it is passed to the reviewers. + + ## Task + {{task_prompt}} review: max_rounds: 2 # review ⇄ fix iterations before asking you what to do on_limit: ask # ask | finish — behaviour when the limit is hit - # context: [prompt, plan, implementer_summary, diff] - # reviewers: # defaults to one reviewer = the lead agent - # - agent: claude - # model: claude-opus-5 - # focus: correctness and edge cases - # - agent: codex - # focus: security and error handling - -# fix: # defaults to the implement stage's agent/model -# agent: claude -# prompt: | # supports {{findings}}, {{diff}}, {{round}}, … -# ... + context: [prompt, implementer_summary, diff] + reviewers: + # Each reviewer may use a different agent/model. Omitted values use Lead. + - # agent: codex + # model: gpt-5.6-sol + # focus: correctness and edge cases + prompt: | + You are a code reviewer in workflow round {{round}}/{{max_rounds}}. + Review the implementation against the task. Do not edit files. Verify + claims against the diff and report only concrete, actionable problems. + + ## Task + {{task_prompt}} + + ## Implementer's summary + {{implementer_summary}} + + ## Working-copy diff + {{diff}} + +fix: + # Uncomment to override the implementation stage. Omitted values inherit it. + # agent: claude + # model: claude-opus-5 + prompt: | + You are the repair stage of workflow round {{round}}/{{max_rounds}}. + Address every reviewer finding: fix it or, if it is factually wrong, + explain why in your final summary. Do not change unrelated code. Verify the + result where feasible and summarize what changed for the next review. + + ## Task + {{task_prompt}} + + ## Findings to address + {{findings}} + + ## Current working-copy diff + {{diff}} + +# Warpforge appends the need_user_input/verdict JSON protocols to these prompts. From 8302e2bd17c30d871c715a394e1c60a27edbc0ac Mon Sep 17 00:00:00 2001 From: Alexandr Shelevalnyk Date: Wed, 29 Jul 2026 21:40:11 +0200 Subject: [PATCH 5/8] feat(workflows): same-session repeat reviews with fresh-session fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repeat review rounds (after a fix) now follow up in the previous reviewer's live session by default instead of spawning a fresh one. The follow-up carries only the delta — fixer summary, last round's findings, fresh diff — plus explicit anti-anchoring instructions: verify every prior finding is actually resolved (not on the fixer's word), reopen what isn't, and re-check the changes for regressions including code found fine last round. - review.reask: same_session | fresh in workflow YAML (default same_session); fresh always staffs new reviewer sessions, whose prompts get an engine-owned verification section with the previous round's findings (appended after custom prompts too, no template changes needed) - dead reviewer session (daemon restart, agent death) falls back to the fresh path automatically; prior_review_children on the run tracks round staffing - finalize now sweeps every stage session the run ever spawned, not just the active ones — completed reviewers/implementers no longer keep running in the background until a daemon restart (verified by a pgrep-based test) Tests: reask parsing; rereview/verification prompt builders; integration: same-session reuse (both review nodes share one task, 3 children total), reask: fresh spawns new reviewers, dead-session fallback via a reject-then- exit mock behavior. --- .changeset/keen-reviewers-remember.md | 15 +++ src/daemon/actor.rs | 99 +++++++++++++++-- src/daemon/mod.rs | 117 ++++++++++++++++++++ src/daemon/workflow.rs | 148 ++++++++++++++++++++++++++ src/workflow_config.rs | 39 ++++++- src/workflows/plan-review-loop.yaml | 3 + src/workflows/review-loop.yaml | 3 + tests/fixtures/mock-acp-workflow.mjs | 13 +++ 8 files changed, 430 insertions(+), 7 deletions(-) create mode 100644 .changeset/keen-reviewers-remember.md diff --git a/.changeset/keen-reviewers-remember.md b/.changeset/keen-reviewers-remember.md new file mode 100644 index 0000000..2acb464 --- /dev/null +++ b/.changeset/keen-reviewers-remember.md @@ -0,0 +1,15 @@ +--- +"warpforge": minor +--- + +Repeat review rounds now continue in the same reviewer session by default: +after a fix, each reviewer receives the fixer's summary, its own previous +findings, and the fresh diff, and must verify every finding is actually +resolved — plus re-check the changes for regressions — instead of reviewing +from scratch. If a reviewer's session is gone (daemon restart, agent death), +the round falls back to a fresh session whose prompt carries the previous +findings for verification. A new `review.reask: same_session | fresh` option +in workflow files controls this per workflow. Finished pipelines also now +shut down every stage agent session, including completed ones — previously +reviewers and implementers kept running in the background until the daemon +restarted. diff --git a/src/daemon/actor.rs b/src/daemon/actor.rs index 9903052..9144b15 100644 --- a/src/daemon/actor.rs +++ b/src/daemon/actor.rs @@ -4596,6 +4596,14 @@ impl Daemon { StageKind::Fix => Some(workflow::format_findings(&run.open_findings)), _ => None, }, + prior_findings: match stage { + // On a repeat round `open_findings` still holds what the last + // review raised — the next reviewers must verify each item. + StageKind::Review if run.round > 1 && !run.open_findings.is_empty() => { + Some(workflow::format_findings(&run.open_findings)) + } + _ => None, + }, round: run.round, max_rounds: run.effective_max_rounds(), guidance, @@ -4614,11 +4622,68 @@ impl Daemon { run.review_collected.clear(); run.reasked.clear(); let round_label = format!("round {}/{}", run.round, run.effective_max_rounds()); + // Repeat rounds follow up in the previous reviewers' live + // sessions (review.reask: same_session, the default): the + // reviewer remembers its own findings and verifies each one + // instead of re-reviewing from scratch. A dead session falls + // back to a fresh spawn whose prompt carries those findings. + let reuse_sessions = run.round > 1 + && run.spec.review.reask == crate::workflow_config::ReaskMode::SameSession; let mut event_agents = Vec::with_capacity(run.spec.review.reviewers.len()); + let mut reused = 0usize; for index in 0..run.spec.review.reviewers.len() { let (agent, model) = run.stage_agent(stage, Some(index)); - let prompt = workflow::build_reviewer_prompt(&run.spec, index, &ctx); let label = run.reviewer_label(index); + if reuse_sessions { + if let Some(prior_id) = run.prior_review_children.get(&index).cloned() { + let followup = workflow::build_rereview_prompt(&ctx); + let delivered = self + .sessions + .get(&prior_id) + .map(|handle| { + handle + .prompt(super::prompt::PreparedPrompt { + content: vec![super::prompt::PromptContent::Text( + followup.clone(), + )], + summaries: vec![], + has_images: false, + }) + .is_ok() + }) + .unwrap_or(false); + if delivered { + self.emit_session( + &prior_id, + wire::SessionUpdate::UserMessage { + text: followup, + attachments: vec![], + }, + ); + // The child was parked Done after its verdict; + // the generic mark_task_running refuses Done + // tasks, so flip it explicitly. + self.workflow_set_child_status(&prior_id, TaskStatus::Running); + run.review_pending.insert(prior_id.clone(), index); + run.active_children.insert(prior_id.clone(), stage); + run.record_stage( + stage, + &prior_id, + &agent, + format!("{label}, {round_label}"), + ); + event_agents.push(wire::WorkflowEventAgent { + task_id: prior_id, + label, + agent, + model, + }); + reused += 1; + continue; + } + } + } + let prompt = workflow::build_reviewer_prompt(&run.spec, index, &ctx); let child_id = self.workflow_spawn_child( &run.project, parent_id, @@ -4639,11 +4704,26 @@ impl Daemon { model, }); } + // Remember this round's staffing for the next reask. + run.prior_review_children = run + .review_pending + .iter() + .map(|(child, index)| (*index, child.clone())) + .collect(); + let detail = if reused > 0 { + format!( + "{} reviewer(s) running; {reused} continuing their previous session to \ + verify their own findings.", + run.review_pending.len() + ) + } else { + format!("{} reviewer(s) running.", run.review_pending.len()) + }; self.workflow_event( parent_id, wire::WorkflowEventKind::StageStarted, format!("Review {round_label} started"), - Some(format!("{} reviewer(s) running.", run.review_pending.len())), + Some(detail), Some(stage), event_agents, wire::WorkflowEventTone::Running, @@ -5165,17 +5245,24 @@ impl Daemon { } } - /// Kill any still-running stage children (their sessions), wait until - /// their processes have exited, then Idle them. + /// Kill the run's stage sessions, wait until their processes have exited, + /// and mark the still-active children Interrupted. Completed stages keep + /// their sessions alive during the run (same-session re-review follows up + /// in them), so the sweep covers every child the run ever spawned — not + /// just the active ones. async fn workflow_stop_children(&mut self, run: &mut WorkflowRun) -> Result<(), String> { - let children: Vec = run.active_children.keys().cloned().collect(); + let active: Vec = run.active_children.keys().cloned().collect(); let mut handles = Vec::new(); - for child_id in children { + for child_id in run.all_children() { if let Some(handle) = self.sessions.remove(&child_id) { handle.cancel(); handles.push(handle); } self.pending_permissions.cleanup_task(&child_id); + } + // Only in-flight stages get their record and task status rewritten; + // completed ones keep their Done/Complete state. + for child_id in active { run.set_record_status(&child_id, wire::OrchNodeStatus::Skipped); if let Some(task) = self.tasks.get_mut(&child_id) { if task.status == TaskStatus::Running || task.status == TaskStatus::Queued { diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 10b5637..2b1acf0 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -680,6 +680,14 @@ mod tests { assert!(graph.nodes.iter().all(|n| n.task_id.is_some())); assert_eq!(graph.nodes[0].kind, wire::OrchNodeKind::Implement); assert_eq!(graph.nodes[2].kind, wire::OrchNodeKind::Fix); + // Default reask mode: round 2 follows up in the SAME reviewer session, + // so both review nodes point at one task. + assert_eq!(graph.nodes[1].kind, wire::OrchNodeKind::Review); + assert_eq!(graph.nodes[3].kind, wire::OrchNodeKind::Review); + assert_eq!( + graph.nodes[1].task_id, graph.nodes[3].task_id, + "same_session re-review must continue the round-1 reviewer session" + ); // The parent conversation is a useful workflow narrative, not just a // sequence of opaque/coalesced stage transitions. Agent cards and @@ -782,6 +790,115 @@ mod tests { .all(|task| task.status == wire::TaskStatus::Done), "consumed workflow stages should be terminal, not idle/needs-review" ); + assert_eq!( + snapshot + .tasks + .iter() + .filter(|task| task.parent_task_id.as_deref() == Some(&parent_id)) + .count(), + 3, + "implement, one reused reviewer session, fix" + ); + // Finalize must sweep every stage session — completed ones included — + // so no mock agent processes outlive the pipeline. + assert_no_stage_processes(&dir).await; + } + + /// Poll until no mock agent process whose command line references this + /// test's tempdir remains alive. Stage sessions are kept alive during a + /// run (same-session re-review follows up in them) and must all be killed + /// at finalize. + async fn assert_no_stage_processes(dir: &tempfile::TempDir) { + let needle = dir.path().to_string_lossy().into_owned(); + for _ in 0..50 { + let alive = std::process::Command::new("pgrep") + .args(["-f", &needle]) + .output() + .map(|out| out.status.success()) + .unwrap_or(false); + if !alive { + return; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + panic!("stage agent processes still alive after the pipeline finished"); + } + + #[tokio::test] + async fn workflow_fresh_reask_spawns_new_reviewers() { + use warpforge_protocol as wire; + let (dir, projects) = workflow_project("name: placeholder\n"); + let reviewer = wf_agent(&dir, "rev.state", "reject approve"); + std::fs::write( + dir.path().join(".warpforge/workflows/test.yaml"), + format!( + "name: Fresh flow\nreview:\n max_rounds: 2\n reask: fresh\n reviewers:\n - agent: {reviewer}\n" + ), + ) + .unwrap(); + let lead = wf_agent(&dir, "impl.state", "impl fix"); + + let store = Store::open_at(std::path::Path::new(":memory:")).ok(); + let daemon = Daemon::spawn(projects, store); + let mut events = daemon.subscribe(); + let parent_id = create_workflow_task(&daemon, &lead).await; + + let done = wait_for_parent(&mut events, &parent_id, "pipeline done", |t| { + t.workflow_run + .as_ref() + .is_some_and(|w| w.stage == wire::WorkflowStage::Done) + }) + .await; + assert_eq!(done.status, TaskStatus::NeedsReview); + let graph = done.orchestration_graph.unwrap(); + assert_eq!(graph.nodes.len(), 4, "{:?}", graph.nodes); + assert_ne!( + graph.nodes[1].task_id, graph.nodes[3].task_id, + "reask: fresh must staff round 2 with a new reviewer session" + ); + } + + /// With the default same_session reask, a reviewer whose session died + /// between rounds falls back to a fresh session — whose prompt carries the + /// previous round's findings for verification. + #[tokio::test] + async fn workflow_dead_reviewer_session_falls_back_to_fresh() { + use warpforge_protocol as wire; + let (dir, projects) = workflow_project("name: placeholder\n"); + // Round 1: reject, then the process exits. Round 2 (fresh fallback + // process) pops the next behavior: approve. + let reviewer = wf_agent(&dir, "rev.state", "reject-die approve"); + std::fs::write( + dir.path().join(".warpforge/workflows/test.yaml"), + format!( + "name: Fallback flow\nreview:\n max_rounds: 2\n reviewers:\n - agent: {reviewer}\n" + ), + ) + .unwrap(); + // A slow fix keeps round 2 far enough away for the reviewer process + // death (~100ms after its verdict) to be observed first. + let lead = wf_agent(&dir, "impl.state", "impl slow-fix"); + + let store = Store::open_at(std::path::Path::new(":memory:")).ok(); + let daemon = Daemon::spawn(projects, store); + let mut events = daemon.subscribe(); + let parent_id = create_workflow_task(&daemon, &lead).await; + + let done = wait_for_parent(&mut events, &parent_id, "pipeline done", |t| { + t.workflow_run + .as_ref() + .is_some_and(|w| w.stage == wire::WorkflowStage::Done) + }) + .await; + assert_eq!(done.status, TaskStatus::NeedsReview); + let run = done.workflow_run.unwrap(); + assert_eq!(run.verdict, Some(wire::WorkflowVerdict::Approve)); + let graph = done.orchestration_graph.unwrap(); + assert_eq!(graph.nodes.len(), 4, "{:?}", graph.nodes); + assert_ne!( + graph.nodes[1].task_id, graph.nodes[3].task_id, + "a dead reviewer session must be replaced by a fresh one" + ); } #[tokio::test] diff --git a/src/daemon/workflow.rs b/src/daemon/workflow.rs index 1eb5450..a0be0b9 100644 --- a/src/daemon/workflow.rs +++ b/src/daemon/workflow.rs @@ -228,6 +228,11 @@ pub struct WorkflowRun { pub reasked: HashMap, /// child task id → stage kind, for routing TurnEnded (single-child stages). pub active_children: HashMap, + /// reviewer index → child task id of the previous review round. With + /// `review.reask: same_session` the next round follows up in these + /// sessions instead of spawning fresh ones. + #[serde(default)] + pub prior_review_children: HashMap, pub history: Vec, /// Attachments from the New Task dialog, delivered to the first stage. pub attachments: Vec, @@ -263,6 +268,7 @@ impl WorkflowRun { deferred_findings: Vec::new(), review_pending: HashMap::new(), review_collected: Vec::new(), + prior_review_children: HashMap::new(), reasked: HashMap::new(), active_children: HashMap::new(), history: Vec::new(), @@ -381,6 +387,17 @@ impl WorkflowRun { self.pending_guidance.take() } + /// Every stage child this run ever spawned. Completed stages keep their + /// sessions alive during the run (same-session re-review needs them), so + /// final cleanup must sweep this full set, not just `active_children`. + pub fn all_children(&self) -> std::collections::HashSet { + self.history + .iter() + .map(|record| record.task_id.clone()) + .chain(self.active_children.keys().cloned()) + .collect() + } + // ── Wire projections ── pub fn wire_info(&self) -> wire::WorkflowRunInfo { @@ -700,6 +717,9 @@ pub struct PromptCtx { pub diff: Option, /// Pre-formatted findings list (fix stage). pub findings: Option, + /// Previous round's findings (repeat review rounds) — the reviewer must + /// verify each one instead of reviewing from scratch. + pub prior_findings: Option, pub round: u32, pub max_rounds: u32, pub guidance: Option, @@ -835,10 +855,62 @@ pub fn build_reviewer_prompt(spec: &WorkflowSpec, reviewer: usize, ctx: &PromptC out } }; + let mut body = body; + if let Some(prior) = ctx.prior_findings.as_deref() { + if !body.ends_with('\n') { + body.push('\n'); + } + body.push('\n'); + push_section( + &mut body, + "Previous round's findings — verify each one", + &format!( + "{prior}\n\nThis is a repeat review after a repair pass. For every finding \ + above, state whether it is actually resolved in the current diff — do not take \ + the fixer's summary on faith; reopen anything that is not. Then check the rest \ + of the changes for regressions the repair may have introduced, including code \ + that was fine last round." + ), + ); + } // Reviewers get no guidance block — guidance targets implement/fix. finish_prompt(body, VERDICT_PROTOCOL, None) } +/// Follow-up message sent into a reviewer's EXISTING session for a repeat +/// round (`review.reask: same_session`). The session already holds the task, +/// the original diff, and this reviewer's own reasoning, so the follow-up +/// carries only what changed since — plus the explicit anti-anchoring +/// instructions: verify every prior finding and re-check for regressions. +pub fn build_rereview_prompt(ctx: &PromptCtx) -> String { + let mut out = format!( + "The repair stage has addressed your review. This is review round {}/{}.\n\n", + ctx.round, ctx.max_rounds + ); + if let Some(summary) = ctx.implementer_summary.as_deref() { + push_section(&mut out, "Fixer's summary", summary); + } + if let Some(findings) = ctx.prior_findings.as_deref() { + push_section( + &mut out, + "Findings raised last round (all reviewers)", + findings, + ); + } + if let Some(diff) = ctx.diff.as_deref() { + push_section(&mut out, "Current working-copy diff", diff); + } + push_section( + &mut out, + "What to do", + "Go through each finding above and verify against the current diff that it is actually \ + resolved — do not take the fixer's summary on faith; reopen anything that is not. Then \ + check the changes for regressions the repair may have introduced, including code you \ + found fine last round. Defending your earlier verdict is not a goal — accuracy is.", + ); + finish_prompt(out, VERDICT_PROTOCOL, None) +} + pub fn build_fix_prompt(spec: &WorkflowSpec, ctx: &PromptCtx) -> String { let body = match spec.fix.prompt.as_deref() { Some(custom) => render_template(custom, &vars_from_ctx(ctx, None)), @@ -1033,6 +1105,7 @@ mod tests { implementer_summary: Some("did things".into()), diff: Some("--- a/x\n+++ b/x".into()), findings: Some("1. [high] — bug (r)".into()), + prior_findings: None, round: 1, max_rounds: 2, guidance: Some("prefer tower middleware".into()), @@ -1085,6 +1158,81 @@ mod tests { assert!(review.contains("\"verdict\"")); } + #[test] + fn repeat_round_reviewer_prompt_verifies_prior_findings() { + let run = run_for("name: X\nreview:\n reviewers:\n - focus: security\n"); + let base = PromptCtx { + task_prompt: "Add rate limiting".into(), + diff: Some("DIFF".into()), + round: 2, + max_rounds: 3, + ..Default::default() + }; + // Round 1 (no prior findings): no verification section. + let first = build_reviewer_prompt(&run.spec, 0, &base); + assert!(!first.contains("Previous round's findings")); + + let ctx = PromptCtx { + prior_findings: Some("1. [high] `a.rs` — bug here (reviewer)".into()), + ..base.clone() + }; + let repeat = build_reviewer_prompt(&run.spec, 0, &ctx); + assert!(repeat.contains("Previous round's findings — verify each one")); + assert!(repeat.contains("bug here")); + assert!(repeat.contains("regressions")); + + // The verification section is engine-owned: custom reviewer prompts + // get it appended too, without any template changes. + let custom = run_for("name: X\nreview:\n reviewers:\n - prompt: \"check {{diff}}\"\n"); + let repeat = build_reviewer_prompt(&custom.spec, 0, &ctx); + assert!(repeat.starts_with("check DIFF")); + assert!(repeat.contains("Previous round's findings — verify each one")); + assert!( + repeat.contains("\"verdict\""), + "protocol still appended last" + ); + } + + #[test] + fn rereview_followup_carries_delta_and_instructions() { + let ctx = PromptCtx { + task_prompt: "irrelevant — the session already has it".into(), + implementer_summary: Some("FIX-DONE: patched the parser".into()), + prior_findings: Some("1. [high] — off-by-one (reviewer)".into()), + diff: Some("THE-NEW-DIFF".into()), + round: 2, + max_rounds: 2, + ..Default::default() + }; + let followup = build_rereview_prompt(&ctx); + assert!(followup.starts_with("The repair stage has addressed your review")); + assert!(followup.contains("round 2/2")); + assert!(followup.contains("FIX-DONE: patched the parser")); + assert!(followup.contains("off-by-one")); + assert!(followup.contains("THE-NEW-DIFF")); + assert!(followup.contains("regressions")); + assert!( + followup.contains("\"verdict\""), + "verdict protocol reminder" + ); + // The session already has the task prompt — the follow-up must not + // re-send it. + assert!(!followup.contains("irrelevant — the session already has it")); + } + + #[test] + fn all_children_spans_history_and_active() { + let mut run = run_for("name: X\n"); + run.record_stage(StageKind::Implement, "t_impl", "claude", "implement".into()); + run.record_stage(StageKind::Review, "t_rev", "claude", "reviewer".into()); + run.active_children.insert("t_fix".into(), StageKind::Fix); + let all = run.all_children(); + assert_eq!(all.len(), 3); + for id in ["t_impl", "t_rev", "t_fix"] { + assert!(all.contains(id), "{id} missing"); + } + } + #[test] fn diff_formatting_and_truncation() { let hunk = wire::Hunk { diff --git a/src/workflow_config.rs b/src/workflow_config.rs index 9412c1f..97f6b45 100644 --- a/src/workflow_config.rs +++ b/src/workflow_config.rs @@ -63,11 +63,29 @@ pub struct StageConfig { pub struct ReviewConfig { pub max_rounds: u32, pub on_limit: OnLimit, + /// How repeat review rounds are staffed after a fix. + #[serde(default)] + pub reask: ReaskMode, pub context: Vec, /// Always 1..=MAX_REVIEWERS entries; defaults to one all-`None` reviewer. pub reviewers: Vec, } +/// Who reviews repeat rounds after a fix. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReaskMode { + /// Follow up in the same reviewer session: it remembers its own findings + /// and verifies each one is actually resolved, at the cost of some + /// anchoring bias. Falls back to a fresh session when the old one is gone + /// (daemon restart, agent death). + #[default] + SameSession, + /// Spawn fresh reviewer sessions every round. The previous round's + /// findings are still included in the prompt for verification. + Fresh, +} + /// What the pipeline does when `max_rounds` is exhausted with open findings. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -174,6 +192,7 @@ struct RawStage { struct RawReview { max_rounds: Option, on_limit: Option, + reask: Option, context: Option>, reviewers: Option>, } @@ -223,7 +242,7 @@ fn collect_unknown_keys(value: &serde_yaml::Value, warnings: &mut Vec) { "fix", ]; const STAGE: &[&str] = &["agent", "model", "prompt"]; - const REVIEW: &[&str] = &["max_rounds", "on_limit", "context", "reviewers"]; + const REVIEW: &[&str] = &["max_rounds", "on_limit", "reask", "context", "reviewers"]; const REVIEWER: &[&str] = &["agent", "model", "focus", "prompt"]; check_keys(value, TOP, "top level", warnings); @@ -335,6 +354,17 @@ fn build_review(raw: RawReview, warnings: &mut Vec) -> Result ReaskMode::default(), + Some("same_session") => ReaskMode::SameSession, + Some("fresh") => ReaskMode::Fresh, + Some(other) => { + return Err(format!( + "review.reask must be `same_session` or `fresh`, got `{other}`" + )) + } + }; + let on_limit = match raw.on_limit.as_deref() { None => OnLimit::Ask, Some("ask") => OnLimit::Ask, @@ -401,6 +431,7 @@ fn build_review(raw: RawReview, warnings: &mut Vec) -> Result process.exit(0), 100); + break; + case "slow-fix": + text("FIX-DONE: addressed the findings (slowly)."); + setTimeout(() => endTurn(msg.id), 600); + break; case "garbage": text("looks fine to me, ship it"); endTurn(msg.id); From a259b08b191d613829562b6916978a67e6d78694 Mon Sep 17 00:00:00 2001 From: Alexandr Shelevalnyk Date: Wed, 29 Jul 2026 23:43:23 +0200 Subject: [PATCH 6/8] =?UTF-8?q?fix(workflows):=20review=20pass=20=E2=80=94?= =?UTF-8?q?=20correctness,=20robustness,=20and=20UX=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from a full pre-PR review of the branch (engine, config/protocol, desktop UX, and prompt/product review), verified and fixed. Engine correctness: - a request_changes verdict with no parseable findings no longer finishes the run as a success; the reviewer's own prose is salvaged as a finding so the repair stage still has something to act on - a reviewer that cannot produce a parseable verdict now abstains like a dead one instead of failing the whole pipeline - a stage whose session never starts fails the run instead of hanging the parent in 'running' forever (start_session reports failure by blocking the child, so no TurnEnded ever arrives) - prompt() is not a liveness check (its channel outlives the child), so same-session re-review, verdict re-asks and question replies now check AcpHandle::is_alive and take their fresh-session fallbacks when it is dead - a restart during a review round no longer burns it ('round 3/2' and an immediate limit decision); the re-run also gets told its predecessor was interrupted so it inspects the partial diff - teardown waits are bounded, so an unkillable agent cannot freeze the actor - teardown trouble is reported in the timeline, not as the RPC's error - cancelling a finished pipeline keeps its terminal status - refuse to start a workflow with no store (every stage would read as empty) - an unreadable persisted run is dropped and its parent marked, instead of re-failing every start with no pipeline controls Agent contract: - the verdict protocol example no longer contradicts its own rule, states that findings must be an array with descriptions, and says low findings are never repaired; scope is calibrated to correctness/regressions and read-only verification is encouraged - the question protocol says when to stop, not just how - severity aliases cover the vocabulary agents actually use (nitpick, suggestion, style, trivial, …) so opinions stop defaulting to Medium - protocol blocks are matched by required key, and stripped from the timeline so the parent shows prose instead of wire format Config: - typed parse from the source text, so errors name the key with line/column - {{plan}} rejected without a plan stage; inert review.context / focus next to custom reviewer prompts now warn; plan: false disables the stage - every persisted collection carries serde(default) so an upgrade cannot orphan in-flight runs; load warnings surface in the run's first event - config_overrides and include_runtime_context reach stage sessions (the dialog's reasoning-effort pick was silently dropped) Desktop: - workflow routing extracted to useWorkflowSend and used by the Mission Control tile too, which previously posted to session.prompt and errored - the composer stays disabled after a run finishes, explaining where to continue, instead of failing with a raw daemon error - Pause is hidden while a question is pending (the daemon rejects it) and a queued pause shows as 'Pausing…' instead of an idle button - stage rows show the daemon's label, so four review rounds are no longer four rows reading 'review'; keys no longer collide when a stage re-runs - pipelines appear in rail/tile previews, blocking on the user raises a toast, and tile stage cards are clickable - semantic ok/warn tones replace dead dark: variants; border-current opacity (which compiles to nothing) replaced; long names/questions truncate with tooltips; limit buttons no longer claim to use typed guidance Docs: spec reconciled with the implementation (state names, role tags, the child-count cap that was never built, round semantics), and the four workflow changesets consolidated into one accurate release note plus the config move. --- .changeset/calm-rivers-follow.md | 12 - .changeset/eager-stages-march.md | 15 - .changeset/fluffy-configs-move.md | 8 + .changeset/keen-reviewers-remember.md | 15 - .changeset/olive-pipelines-steer.md | 18 - .changeset/wide-pipelines-arrive.md | 26 ++ crates/warpforge-protocol/src/lib.rs | 4 + desktop/src/components/ChatComposer.test.tsx | 25 +- desktop/src/components/ChatComposer.tsx | 64 +--- desktop/src/components/SessionRailCard.tsx | 7 +- .../src/components/TaskAgentSwitcher.test.tsx | 5 +- desktop/src/components/TaskAgentSwitcher.tsx | 25 +- .../src/components/TaskComposeBar.test.tsx | 11 +- desktop/src/components/TaskComposeBar.tsx | 25 +- .../src/components/WorkflowControls.test.tsx | 18 +- desktop/src/components/WorkflowControls.tsx | 27 +- desktop/src/components/WorkflowEventLine.tsx | 18 +- desktop/src/hooks/useDaemonEvents.tsx | 52 ++- desktop/src/hooks/useWorkflowSend.test.ts | 100 ++++++ desktop/src/hooks/useWorkflowSend.ts | 88 +++++ desktop/src/lib/sessionPreview.ts | 5 + desktop/src/lib/snooze.test.ts | 8 +- desktop/src/lib/taskGroups.ts | 4 +- desktop/src/lib/terminalWorkspace.ts | 12 +- desktop/src/lib/updater.test.ts | 4 +- desktop/src/lib/workflow.ts | 4 + desktop/src/protocol.ts | 2 + desktop/src/views/Board.tsx | 20 +- desktop/src/views/MissionControl.test.ts | 5 +- desktop/src/views/MissionControl.tsx | 32 +- desktop/src/views/NewTaskDialog.tsx | 12 +- .../views/projects/RemoveProjectDialog.tsx | 10 +- .../src/views/task-detail/SubtasksRail.tsx | 14 +- src/daemon/acp.rs | 27 +- src/daemon/actor.rs | 334 +++++++++++++----- src/daemon/mod.rs | 22 +- src/daemon/server.rs | 2 + src/daemon/workflow.rs | 190 ++++++++-- src/main.rs | 10 +- src/workflow_config.rs | 140 +++++++- src/workflows/plan-review-loop.yaml | 5 +- src/workflows/review-loop.yaml | 5 +- 42 files changed, 1062 insertions(+), 368 deletions(-) delete mode 100644 .changeset/calm-rivers-follow.md delete mode 100644 .changeset/eager-stages-march.md create mode 100644 .changeset/fluffy-configs-move.md delete mode 100644 .changeset/keen-reviewers-remember.md delete mode 100644 .changeset/olive-pipelines-steer.md create mode 100644 .changeset/wide-pipelines-arrive.md create mode 100644 desktop/src/hooks/useWorkflowSend.test.ts create mode 100644 desktop/src/hooks/useWorkflowSend.ts diff --git a/.changeset/calm-rivers-follow.md b/.changeset/calm-rivers-follow.md deleted file mode 100644 index d11bbb6..0000000 --- a/.changeset/calm-rivers-follow.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"warpforge": minor ---- - -Adds the foundation for configurable task workflows. Projects can now define -workflow templates as YAML files in `.warpforge/workflows/` — configuring the -planning stage, reviewer agents and models, review context, and the review ⇄ -fix iteration limit — and two built-in templates (Review loop, Plan + review -loop) ship with the app and can be copied into a project for customization. -The workspace config also gains a new preferred home at -`.warpforge/workspace.yaml`: existing root-level config files keep working -unchanged, while newly generated configs land in the `.warpforge/` directory. diff --git a/.changeset/eager-stages-march.md b/.changeset/eager-stages-march.md deleted file mode 100644 index ff19b8c..0000000 --- a/.changeset/eager-stages-march.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"warpforge": minor ---- - -Workflow pipelines now actually run. Creating a task with a workflow selected -drives it through a deterministic plan → implement → review ⇄ fix pipeline: -each stage is a child agent session, reviewers run in parallel and return a -structured verdict, and fix rounds are hard-capped by the workflow's limit. -The parent task narrates progress as a timeline, and the pipeline suspends -for you when it needs input: a stage can ask a question mid-run, and hitting -the review limit asks whether to grant more rounds, finish as is, or stop — -optionally with extra guidance for the next fix. Pipelines can be paused at a -stage boundary and resumed (with notes), survive daemon restarts by parking -at their last safe point, and never commit anything: a finished run lands in -Needs review for you to inspect and commit. diff --git a/.changeset/fluffy-configs-move.md b/.changeset/fluffy-configs-move.md new file mode 100644 index 0000000..35fd69b --- /dev/null +++ b/.changeset/fluffy-configs-move.md @@ -0,0 +1,8 @@ +--- +"warpforge": patch +--- + +The workspace config has a new preferred home at `.warpforge/workspace.yaml`, +alongside the new `.warpforge/workflows/` directory. Existing config files in +the project root keep working exactly as before; only newly generated configs +land in the `.warpforge/` directory. diff --git a/.changeset/keen-reviewers-remember.md b/.changeset/keen-reviewers-remember.md deleted file mode 100644 index 2acb464..0000000 --- a/.changeset/keen-reviewers-remember.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"warpforge": minor ---- - -Repeat review rounds now continue in the same reviewer session by default: -after a fix, each reviewer receives the fixer's summary, its own previous -findings, and the fresh diff, and must verify every finding is actually -resolved — plus re-check the changes for regressions — instead of reviewing -from scratch. If a reviewer's session is gone (daemon restart, agent death), -the round falls back to a fresh session whose prompt carries the previous -findings for verification. A new `review.reask: same_session | fresh` option -in workflow files controls this per workflow. Finished pipelines also now -shut down every stage agent session, including completed ones — previously -reviewers and implementers kept running in the background until the daemon -restarted. diff --git a/.changeset/olive-pipelines-steer.md b/.changeset/olive-pipelines-steer.md deleted file mode 100644 index 8a88b20..0000000 --- a/.changeset/olive-pipelines-steer.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"warpforge": minor ---- - -Workflows are now selectable and steerable from the app. The New Task dialog -gets a Workflow picker listing your project's templates alongside the built-in -ones — a built-in can be copied into the project in one click so you can edit -it, and a template with a broken YAML is listed with its error rather than -quietly missing. Picking a workflow runs the task as a pipeline instead of a -single agent session, and the agent chip becomes the lead agent for any stage -the workflow doesn't assign. - -A running pipeline shows its stage, round, and latest review verdict above the -composer, with Pause and Resume. When it needs you — a stage asked a question, -or review rounds ran out — the task surfaces in "Needs you" and the composer -turns into the answer box: typing replies to the asking stage, resumes a pause -with your note as guidance, or buys another fix round. Board cards carry a -stage badge that turns amber when the pipeline is waiting on you. diff --git a/.changeset/wide-pipelines-arrive.md b/.changeset/wide-pipelines-arrive.md new file mode 100644 index 0000000..a6146d6 --- /dev/null +++ b/.changeset/wide-pipelines-arrive.md @@ -0,0 +1,26 @@ +--- +"warpforge": minor +--- + +Adds configurable workflows: a task can now run as a pipeline of agent stages +instead of a single session. A workflow is a YAML file in your project's +`.warpforge/workflows/`, and it decides which agent and model runs each stage, +what each stage is told to do, what the reviewers see, and how many review +rounds are allowed. Two built-in templates ship with the app — "Implement + +review loop" and "Plan + implement + review loop" — and either can be copied +into a project in one click to customize. + +Pick a workflow in the New Task dialog and the daemon drives the run: it plans +(if the workflow asks for it), implements, then loops review and repair until +the reviewers approve or the round limit runs out. Reviewers can be several +different agents at once and return structured verdicts, and a repeat round +continues in the same reviewer's session so it verifies its own findings +instead of reviewing from scratch. + +The pipeline reports to the parent task as a timeline of stages, each with the +agents that ran it — click one to open that agent's own session. It also stops +for you when it needs to: a stage can ask a question, and running out of review +rounds asks whether to grant more, finish as is, or stop. Pipelines can be +paused between stages and resumed with extra guidance, survive a daemon restart +by parking at their last safe point, and never commit anything — a finished run +lands in Needs review for you to inspect. diff --git a/crates/warpforge-protocol/src/lib.rs b/crates/warpforge-protocol/src/lib.rs index 91f9e96..8348616 100644 --- a/crates/warpforge-protocol/src/lib.rs +++ b/crates/warpforge-protocol/src/lib.rs @@ -1485,6 +1485,10 @@ pub struct WorkflowRunInfo { /// opens on this, and it drives the attention ("Needs you") rail. #[serde(default, skip_serializing_if = "Option::is_none")] pub waiting: Option, + /// A pause has been requested and takes effect when the running stage + /// finishes. Lets the UI show progress instead of an idle Pause button. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub pause_requested: bool, } /// Pipeline position for display. diff --git a/desktop/src/components/ChatComposer.test.tsx b/desktop/src/components/ChatComposer.test.tsx index 5f117f4..1425e39 100644 --- a/desktop/src/components/ChatComposer.test.tsx +++ b/desktop/src/components/ChatComposer.test.tsx @@ -101,23 +101,34 @@ describe("ChatComposer — workflow parents", () => { }); }); - it("disables input while the pipeline runs unattended", () => { + it("disables input while the pipeline runs unattended and points at the stages", () => { renderComposer(task(run())); expect(screen.getByRole("textbox")).toBeDisabled(); expect(screen.getByRole("textbox")).toHaveAttribute( "placeholder", - expect.stringContaining("Subtasks"), + expect.stringContaining("open a stage above"), ); }); - it("hard-stops a running workflow from the parent composer", async () => { + it("leaves stopping a pipeline to WorkflowControls, not the composer", () => { + // Two destructive controls a few pixels apart is a trap; the labelled Stop + // in WorkflowControls owns this for pipelines. renderComposer(task(run())); - await userEvent.click(screen.getByRole("button", { name: /^stop$/i })); - expect(request).toHaveBeenCalledWith("task.cancel", { task_id: "t_1" }); + expect(screen.queryByRole("button", { name: /^stop$/i })).not.toBeInTheDocument(); }); - it("re-enables input once the pipeline finishes", () => { + it("keeps input disabled after the pipeline finishes, with an explanation", () => { + // The parent never has an agent session, so a message here could only fail + // with a raw daemon error. renderComposer(task({ ...run(), stage: "done", waiting: null })); - expect(screen.getByRole("textbox")).not.toBeDisabled(); + const input = screen.getByRole("textbox"); + expect(input).toBeDisabled(); + expect(input).toHaveAttribute("placeholder", expect.stringContaining("has finished")); + }); + + it("never prompts the session-less parent, even with no barrier pending", async () => { + renderComposer(task({ ...run(), stage: "done", waiting: null })); + // Disabled input cannot submit, but the routing must refuse regardless. + expect(request).not.toHaveBeenCalledWith("session.prompt", expect.anything()); }); }); diff --git a/desktop/src/components/ChatComposer.tsx b/desktop/src/components/ChatComposer.tsx index edd6299..3a73bae 100644 --- a/desktop/src/components/ChatComposer.tsx +++ b/desktop/src/components/ChatComposer.tsx @@ -1,6 +1,7 @@ import { forwardRef, memo, useCallback } from "react"; import { daemon } from "../daemon"; +import { useWorkflowSend } from "../hooks/useWorkflowSend"; import type { ContextUsage } from "../lib/sessionUsage"; import type { CommandInfo, ProjectFile, PromptSubmission, TaskInfo } from "../protocol"; import { AgentConfigBar } from "./AgentConfigBar"; @@ -22,52 +23,25 @@ export const ChatComposer = memo( { commands, contextUsage, files, filesLoading, imageSupported, onBeforeSend, task }, ref, ) { - // A workflow parent has no agent session: its messages steer the pipeline - // instead, and are only accepted at the points where the daemon is waiting - // for a human (see WorkflowControls for the button-driven half). - const run = task.workflowRun ?? null; - const waiting = run?.waiting ?? null; + const workflow = useWorkflowSend(task); const onSend = useCallback( async (submission: PromptSubmission) => { onBeforeSend(); - const text = submission.text.trim(); - if (waiting) { - switch (waiting.kind) { - case "question": - await daemon.workflowReply(task.id, text); - return; - case "paused": - await daemon.workflowResume(task.id, text || undefined); - return; - case "limit": - // Typed guidance rides along with one more round of fixes. - await daemon.workflowDecide(task.id, "extend", { - note: text || undefined, - rounds: 1, - }); - return; - } - } + if (await workflow.send(submission)) return; await daemon.request("session.prompt", { task_id: task.id, ...submission }); }, - [onBeforeSend, task.id, waiting], + [onBeforeSend, task.id, workflow], ); // For a workflow parent the daemon maps task.cancel onto stopping the // whole pipeline, so the composer's stop button stays the terminal // "kill it" affordance next to WorkflowControls' soft pause. - const onCancel = useCallback( - async () => { - await daemon.request("task.cancel", { task_id: task.id }); - }, - [task.id], - ); + const onCancel = useCallback(async () => { + await daemon.request("task.cancel", { task_id: task.id }); + }, [task.id]); const isRunning = task.status === "running" || task.status === "queued"; - // While a pipeline runs unattended there is nobody to read a message — - // intervene in a stage's own session (Subtasks) instead. - const workflowBusy = !!run && !waiting && run.stage !== "done" && run.stage !== "failed"; return ( 0 ? ( @@ -90,21 +64,3 @@ export const ChatComposer = memo( ); }), ); - -/** `undefined` keeps the Composer's own default for non-workflow tasks. */ -function workflowPlaceholder( - kind: "question" | "limit" | "paused" | undefined, - busy: boolean, -): string | undefined { - if (busy) return "The pipeline is running — open a stage under Subtasks to steer it."; - switch (kind) { - case "question": - return "Answer the stage's question…"; - case "paused": - return "Add guidance for the next stage, then send to resume…"; - case "limit": - return "Add guidance for another fix round, or pick an option above…"; - default: - return undefined; - } -} diff --git a/desktop/src/components/SessionRailCard.tsx b/desktop/src/components/SessionRailCard.tsx index 428b0c0..5db8509 100644 --- a/desktop/src/components/SessionRailCard.tsx +++ b/desktop/src/components/SessionRailCard.tsx @@ -260,7 +260,12 @@ const SessionRailCard = memo(function SessionRailCard({

)} {reason && ( -

+ // `reason` can now be free-form agent text (a pipeline's question), so + // it needs a tooltip when truncated. +

{reason}

)} diff --git a/desktop/src/components/TaskAgentSwitcher.test.tsx b/desktop/src/components/TaskAgentSwitcher.test.tsx index be8dd52..ff62495 100644 --- a/desktop/src/components/TaskAgentSwitcher.test.tsx +++ b/desktop/src/components/TaskAgentSwitcher.test.tsx @@ -100,7 +100,8 @@ describe("TaskAgentSwitcher", () => { "Stages 1", ); await user.click(screen.getByRole("button", { name: /current: workflow/i })); - expect(await screen.findByRole("menuitem", { name: /implement · codex: running/i })) - .toBeInTheDocument(); + expect( + await screen.findByRole("menuitem", { name: /implement · codex: running/i }), + ).toBeInTheDocument(); }); }); diff --git a/desktop/src/components/TaskAgentSwitcher.tsx b/desktop/src/components/TaskAgentSwitcher.tsx index 6e456b3..a09712d 100644 --- a/desktop/src/components/TaskAgentSwitcher.tsx +++ b/desktop/src/components/TaskAgentSwitcher.tsx @@ -29,21 +29,20 @@ export const TaskAgentSwitcher = memo(function TaskAgentSwitcher({ const currentIndex = members.findIndex((member) => member.id === currentTaskId); const current = members[currentIndex] ?? tree.task; const workflow = Boolean(tree.task.workflowRun); - const stageByTaskId = useMemo( - () => { - const result = new Map(); - for (const node of tree.task.orchestrationGraph?.nodes ?? []) { - if (node.taskId) result.set(node.taskId, node.id); - } - return result; - }, - [tree.task.orchestrationGraph?.nodes], - ); + const stageByTaskId = useMemo(() => { + const result = new Map(); + for (const node of tree.task.orchestrationGraph?.nodes ?? []) { + if (node.taskId) result.set(node.taskId, node.id); + } + return result; + }, [tree.task.orchestrationGraph?.nodes]); const memberLabel = useCallback( (member: (typeof members)[number], index: number) => { if (index === 0) return workflow ? "Workflow" : "Lead"; const stage = stageByTaskId.get(member.id); - return stage ? `${stage} · ${agentDisplayName(member.agent)}` : agentDisplayName(member.agent); + return stage + ? `${stage} · ${agentDisplayName(member.agent)}` + : agentDisplayName(member.agent); }, [stageByTaskId, workflow], ); @@ -67,7 +66,9 @@ export const TaskAgentSwitcher = memo(function TaskAgentSwitcher({ className="flex h-7 shrink-0 items-center gap-1.5 rounded px-2 text-xs text-muted-foreground hover:bg-secondary hover:text-foreground" > - {workflow ? "Stages" : "Agents"} {members.length - 1} + + {workflow ? "Stages" : "Agents"} {members.length - 1} + · {currentIndex === 0 ? ( diff --git a/desktop/src/components/TaskComposeBar.test.tsx b/desktop/src/components/TaskComposeBar.test.tsx index 0f73178..8c57ae4 100644 --- a/desktop/src/components/TaskComposeBar.test.tsx +++ b/desktop/src/components/TaskComposeBar.test.tsx @@ -81,7 +81,7 @@ describe("TaskComposeBar — workflow picker", () => { const user = userEvent.setup(); renderBar(); await user.click(screen.getByRole("button", { name: /workflow/i })); - await user.click(screen.getByRole("menuitem", { name: /Review loop/ })); + await user.click(screen.getByRole("menuitem", { name: /implement → review×2 → fix/ })); expect(onWorkflowChange).toHaveBeenCalledWith("review-loop"); }); @@ -99,16 +99,17 @@ describe("TaskComposeBar — workflow picker", () => { const user = userEvent.setup(); renderBar(); await user.click(screen.getByRole("button", { name: /workflow/i })); - await user.click(screen.getByRole("menuitem", { name: /copy to project/i })); + await user.click(screen.getByRole("menuitem", { name: /copy review loop to this project/i })); expect(onEjectWorkflow).toHaveBeenCalledWith("review-loop"); expect(onWorkflowChange).not.toHaveBeenCalled(); }); - it("summarizes the selected workflow and relabels the agent as the lead", () => { + it("summarizes the selected workflow and labels the agent as the per-stage default", () => { renderBar({ workflow: "review-loop" }); - expect(screen.getByText(/implement → review×2 → fix/)).toBeInTheDocument(); + expect(screen.getByText(/Stages: implement → review×2 → fix/)).toBeInTheDocument(); expect(screen.getByText(/up to 2 review rounds/)).toBeInTheDocument(); - expect(screen.getByText("Lead agent")).toBeInTheDocument(); + // Nothing is "led": the parent has no session, this is just the fallback. + expect(screen.getByText("Default agent")).toBeInTheDocument(); }); it("locks the orchestrator toggle while a workflow is selected", () => { diff --git a/desktop/src/components/TaskComposeBar.tsx b/desktop/src/components/TaskComposeBar.tsx index d1e11af..a23b064 100644 --- a/desktop/src/components/TaskComposeBar.tsx +++ b/desktop/src/components/TaskComposeBar.tsx @@ -100,7 +100,7 @@ export function TaskComposeBar({
- {orchChat || workflow ? "Lead agent" : "Agent"} + {orchChat ? "Lead agent" : workflow ? "Default agent" : "Agent"} {agentChoices.map((a) => ( onAgentChange(a.id)}> @@ -147,11 +147,15 @@ export function TaskComposeBar({ {selectedWorkflow && (

{selectedWorkflow.description ? `${selectedWorkflow.description} ` : ""} - {(selectedWorkflow.stages ?? []).join(" \u2192 ")} - {selectedWorkflow.maxRounds - ? `, up to ${selectedWorkflow.maxRounds} review round${selectedWorkflow.maxRounds === 1 ? "" : "s"}` - : ""} - {". The agent above leads any stage the workflow doesn\u2019t assign."} + {(selectedWorkflow.stages ?? []).length > 0 && + `Stages: ${(selectedWorkflow.stages ?? []).join(" \u2192 ")}${ + selectedWorkflow.maxRounds + ? ` (up to ${selectedWorkflow.maxRounds} review round${ + selectedWorkflow.maxRounds === 1 ? "" : "s" + })` + : "" + }. `} + {"Stages that don\u2019t name their own agent use the one selected above."}

)}
@@ -267,9 +271,11 @@ function WorkflowPicker({ disabled && "cursor-not-allowed opacity-40", )} > - - {selected ? selected.name : "Workflow"} - + + + {selected ? selected.name : "Workflow"} + + @@ -324,6 +330,7 @@ function WorkflowPicker({ onEject(w.id)} + aria-label={`Copy ${w.name} to this project`} > diff --git a/desktop/src/components/WorkflowControls.test.tsx b/desktop/src/components/WorkflowControls.test.tsx index 2fe51c5..da873ec 100644 --- a/desktop/src/components/WorkflowControls.test.tsx +++ b/desktop/src/components/WorkflowControls.test.tsx @@ -75,7 +75,7 @@ describe("WorkflowControls", () => { ); expect(screen.getByText(/open findings: 2 high/)).toBeInTheDocument(); expect(screen.getByText("Review limit reached")).toBeInTheDocument(); - expect(screen.getByText(/guidance typed below/i)).toBeInTheDocument(); + expect(screen.getByText(/type it below and press Send/i)).toBeInTheDocument(); // Pausing makes no sense while a decision is pending. expect(screen.queryByRole("button", { name: /pause/i })).not.toBeInTheDocument(); @@ -128,6 +128,22 @@ describe("WorkflowControls", () => { ); }); + it("hides Pause while a stage question is pending — the daemon rejects it", async () => { + render( + , + ); + expect(screen.queryByRole("button", { name: /pause/i })).not.toBeInTheDocument(); + expect(workflowPause).not.toHaveBeenCalled(); + }); + + it("shows a queued pause as progress instead of an idle button", () => { + render(); + const button = screen.getByRole("button", { name: /pausing/i }); + expect(button).toBeDisabled(); + }); + it("drops the controls once the pipeline is finished", () => { render(); expect(screen.queryByRole("button", { name: /pause/i })).not.toBeInTheDocument(); diff --git a/desktop/src/components/WorkflowControls.tsx b/desktop/src/components/WorkflowControls.tsx index 6a9d78e..a395ee1 100644 --- a/desktop/src/components/WorkflowControls.tsx +++ b/desktop/src/components/WorkflowControls.tsx @@ -31,7 +31,9 @@ export const WorkflowControls = memo(function WorkflowControls({ task }: { task: try { await fn(); } catch (e) { - toast.error(`Could not ${label}`, { description: String(e) }); + toast.error(`Could not ${label}`, { + description: e instanceof Error ? e.message : String(e), + }); } finally { setBusyAction(null); } @@ -42,7 +44,7 @@ export const WorkflowControls = memo(function WorkflowControls({ task }: { task:
- {!finished && waiting?.kind !== "limit" && ( + {!finished && (waiting === null || waiting.kind === "paused") && ( <> {waiting?.kind === "paused" ? ( )} @@ -216,7 +222,8 @@ function LimitDecision({

- Guidance typed below is used only when you continue with another round. + These buttons continue without guidance. To add guidance, type it below and press Send + instead — that runs one more round with your note.

); @@ -226,7 +233,9 @@ function StageIndicator({ run }: { run: WorkflowRunInfo }) { const waiting = run.waiting ?? null; return ( - {run.workflowName} + + {run.workflowName} + · {waiting?.kind === "paused" @@ -242,9 +251,7 @@ function StageIndicator({ run }: { run: WorkflowRunInfo }) { {run.verdict === "approve" ? "approved" : "changes requested"} diff --git a/desktop/src/components/WorkflowEventLine.tsx b/desktop/src/components/WorkflowEventLine.tsx index 9ae973a..d36991b 100644 --- a/desktop/src/components/WorkflowEventLine.tsx +++ b/desktop/src/components/WorkflowEventLine.tsx @@ -44,7 +44,10 @@ export function WorkflowEventLine({
- + {update.title} {!showAgentCards && @@ -70,7 +73,12 @@ export function WorkflowEventLine({ type="button" disabled={!onOpenTask} onClick={() => onOpenTask?.(agent.taskId)} - className="group flex min-w-44 max-w-full items-center gap-2 rounded-md border border-current/20 bg-background/45 px-2.5 py-2 text-left text-foreground transition-colors hover:border-current/40 hover:bg-background/70 disabled:pointer-events-none" + className={cn( + "group flex min-w-44 max-w-full items-center gap-2 rounded-md border border-border bg-background/45 px-2.5 py-2 text-left text-foreground transition-colors", + onOpenTask + ? "hover:border-primary/40 hover:bg-background/70" + : "cursor-default disabled:pointer-events-none", + )} aria-label={`Open ${agent.label} agent session`} > @@ -89,7 +97,9 @@ export function WorkflowEventLine({ )} - + {onOpenTask && ( + + )} ))}
@@ -99,7 +109,7 @@ export function WorkflowEventLine({ (compact ? ( {update.detail} ) : ( -
+
{update.detail}
))} diff --git a/desktop/src/hooks/useDaemonEvents.tsx b/desktop/src/hooks/useDaemonEvents.tsx index b7338d2..7f97427 100644 --- a/desktop/src/hooks/useDaemonEvents.tsx +++ b/desktop/src/hooks/useDaemonEvents.tsx @@ -18,6 +18,16 @@ function attentionToastTitle(status: TaskStatus): string { return "Session interrupted"; } +/** The barrier a pipeline is parked at, or null when it needs nothing. */ +function waitingKind(task: TaskInfo): "question" | "limit" | null { + const kind = task.workflowRun?.waiting?.kind; + return kind === "question" || kind === "limit" ? kind : null; +} + +function workflowToastTitle(kind: "question" | "limit"): string { + return kind === "question" ? "Pipeline needs your answer" : "Pipeline is out of review rounds"; +} + export function useDaemonEvents() { const seenPermissionIds = useRef(new Set()); const notificationsReady = useRef(false); @@ -39,6 +49,34 @@ export function useDaemonEvents() { ui.openTask(taskId); ui.setAttentionOpen(false); }; + const notifyWorkflowWaiting = (task: TaskInfo, kind: "question" | "limit") => { + const toastId = `attention:workflow:${task.id}:${kind}`; + const question = task.workflowRun?.waiting?.question; + toast.custom( + (sonnerId) => ( + toast.dismiss(sonnerId)} + onOpen={() => { + openInChat(task.id); + toast.dismiss(sonnerId); + }} + /> + ), + { + action: null, + cancel: null, + description: null, + duration: 10_000, + icon: null, + id: toastId, + richColors: false, + unstyled: true, + }, + ); + }; const notifyTask = (task: TaskInfo) => { const toastId = `attention:${task.id}:${task.status}`; toast.custom( @@ -138,9 +176,19 @@ export function useDaemonEvents() { } if (event.event === "task.updated") { - const previous = daemon + const previousTask = daemon .getState() - .snapshot.tasks.find((task) => task.id === event.data.id)?.status; + .snapshot.tasks.find((task) => task.id === event.data.id); + const previous = previousTask?.status; + // A pipeline parking on the user is an attention state the coarse task + // status cannot express (it stays Idle), so it needs its own toast. + const wasWaiting = previousTask ? waitingKind(previousTask) : null; + const nowWaiting = waitingKind(event.data); + if (nowWaiting && nowWaiting !== wasWaiting) { + notifyWorkflowWaiting(event.data, nowWaiting); + } else if (!nowWaiting && wasWaiting) { + toast.dismiss(`attention:workflow:${event.data.id}:${wasWaiting}`); + } if (ATTENTION_STATUS.has(event.data.status) && previous !== event.data.status) { if (previous && ATTENTION_STATUS.has(previous)) { toast.dismiss(`attention:${event.data.id}:${previous}`); diff --git a/desktop/src/hooks/useWorkflowSend.test.ts b/desktop/src/hooks/useWorkflowSend.test.ts new file mode 100644 index 0000000..b42839f --- /dev/null +++ b/desktop/src/hooks/useWorkflowSend.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PromptSubmission, TaskInfo, WorkflowRunInfo } from "../protocol"; +import { useWorkflowSend } from "./useWorkflowSend"; + +const workflowReply = vi.fn<(...args: unknown[]) => Promise>(async () => {}); +const workflowResume = vi.fn<(...args: unknown[]) => Promise>(async () => {}); +const workflowDecide = vi.fn<(...args: unknown[]) => Promise>(async () => {}); + +vi.mock("../daemon", () => ({ + daemon: { + workflowDecide: (...args: unknown[]) => workflowDecide(...(args as [])), + workflowReply: (...args: unknown[]) => workflowReply(...(args as [])), + workflowResume: (...args: unknown[]) => workflowResume(...(args as [])), + }, +})); + +// The hook only reads props and returns callbacks, so calling it outside a +// renderer is enough to pin the routing table. +vi.mock("react", async () => { + const actual = await vi.importActual("react"); + return { ...actual, useCallback: (fn: unknown) => fn }; +}); + +function task(run: Partial | null): TaskInfo { + return { + agent: "claude", + blockedReason: null, + createdAt: 1, + filesChanged: 0, + id: "t_1", + project: "warpforge", + prompt: "do it", + status: "running", + tags: [], + title: "", + updatedAt: 1, + workflowRun: run + ? { + maxRounds: 2, + round: 1, + stage: "review", + workflowId: "wf", + workflowName: "Loop", + ...run, + } + : null, + }; +} + +const submission = (text: string): PromptSubmission => ({ attachments: [], text }); + +describe("useWorkflowSend", () => { + beforeEach(() => vi.clearAllMocks()); + + it("declines to handle a plain task so the caller prompts its session", async () => { + const send = useWorkflowSend(task(null)); + expect(send.isWorkflow).toBe(false); + expect(send.disabled).toBe(false); + expect(await send.send(submission("hello"))).toBe(false); + }); + + it("routes each barrier to its own RPC", async () => { + expect( + await useWorkflowSend(task({ waiting: { kind: "question" } })).send(submission("Postgres")), + ).toBe(true); + expect(workflowReply).toHaveBeenCalledWith("t_1", "Postgres"); + + await useWorkflowSend(task({ waiting: { kind: "paused" } })).send(submission("carry on")); + expect(workflowResume).toHaveBeenCalledWith("t_1", "carry on"); + + await useWorkflowSend(task({ waiting: { kind: "limit" } })).send(submission("focus here")); + expect(workflowDecide).toHaveBeenCalledWith("t_1", "extend", { + note: "focus here", + rounds: 1, + }); + }); + + it("swallows messages for a session-less parent rather than failing an RPC", async () => { + // A running or finished pipeline has no addressee; prompting the parent + // would surface a raw "no live or resumable agent session" error. + const handled = await Promise.all( + (["review", "done", "failed"] as const).map((stage) => + useWorkflowSend(task({ stage, waiting: null })).send(submission("hi")), + ), + ); + expect(handled).toEqual([true, true, true]); + expect(workflowReply).not.toHaveBeenCalled(); + expect(workflowResume).not.toHaveBeenCalled(); + expect(workflowDecide).not.toHaveBeenCalled(); + }); + + it("explains why the box is disabled, differently for running vs finished", () => { + expect(useWorkflowSend(task({ stage: "review" })).placeholder).toMatch(/open a stage above/); + expect(useWorkflowSend(task({ stage: "done" })).placeholder).toMatch(/has finished/); + expect(useWorkflowSend(task({ waiting: { kind: "question" } })).placeholder).toMatch( + /Answer the stage/, + ); + }); +}); diff --git a/desktop/src/hooks/useWorkflowSend.ts b/desktop/src/hooks/useWorkflowSend.ts new file mode 100644 index 0000000..70ebd25 --- /dev/null +++ b/desktop/src/hooks/useWorkflowSend.ts @@ -0,0 +1,88 @@ +import { useCallback } from "react"; + +import { daemon } from "../daemon"; +import type { PromptSubmission, TaskInfo } from "../protocol"; + +export interface WorkflowSend { + /** True when this task is a workflow parent — it has no agent session. */ + isWorkflow: boolean; + /** True while the pipeline runs unattended: a message has no addressee. */ + disabled: boolean; + /** `undefined` keeps the composer's own default placeholder. */ + placeholder: string | undefined; + /** + * Deliver a composed message. Returns true when it was handled as pipeline + * input, so callers must not also prompt the (nonexistent) parent session. + */ + send: (submission: PromptSubmission) => Promise; +} + +/** + * Routing for messages typed into a workflow parent's composer. + * + * The parent task has no ACP session of its own — the daemon drives its stages + * — so `session.prompt` would be rejected. A message is only meaningful at the + * barriers where the pipeline is waiting for a human, and each barrier has its + * own RPC. Shared by every composer that can be pointed at a task, so no + * surface can accidentally fall through to the raw prompt path. + */ +export function useWorkflowSend(task: TaskInfo): WorkflowSend { + const run = task.workflowRun ?? null; + const waiting = run?.waiting ?? null; + const finished = run?.stage === "done" || run?.stage === "failed"; + const disabled = !!run && !waiting; + + const send = useCallback( + async (submission: PromptSubmission): Promise => { + if (!run) return false; + const text = submission.text.trim(); + switch (waiting?.kind) { + case "question": + await daemon.workflowReply(task.id, text); + return true; + case "paused": + await daemon.workflowResume(task.id, text || undefined); + return true; + case "limit": + // Typed guidance rides along with one more round of fixes. + await daemon.workflowDecide(task.id, "extend", { + note: text || undefined, + rounds: 1, + }); + return true; + default: + // Nothing is listening: swallow rather than prompting a parent that + // has no session (which fails with a raw daemon error). + return true; + } + }, + [run, task.id, waiting], + ); + + return { + disabled, + isWorkflow: !!run, + placeholder: placeholderFor(waiting?.kind, !!run, finished), + send, + }; +} + +function placeholderFor( + kind: "question" | "limit" | "paused" | undefined, + isWorkflow: boolean, + finished: boolean, +): string | undefined { + switch (kind) { + case "question": + return "Answer the stage's question…"; + case "paused": + return "Add guidance for the next stage, then send to resume…"; + case "limit": + return "Add guidance for another fix round, or pick an option above…"; + default: + if (!isWorkflow) return undefined; + return finished + ? "This pipeline has finished — open a stage above to continue with that agent, or start a new task." + : "The pipeline is running — open a stage above to message that agent."; + } +} diff --git a/desktop/src/lib/sessionPreview.ts b/desktop/src/lib/sessionPreview.ts index 488cb4f..c0c510d 100644 --- a/desktop/src/lib/sessionPreview.ts +++ b/desktop/src/lib/sessionPreview.ts @@ -128,6 +128,11 @@ export function latestSessionPreview( continue; } + if (update.kind === "workflow_event") { + // A workflow parent's transcript is almost entirely these, so without a + // branch here a running pipeline shows no activity in the rail or tiles. + return { kind: "message", label: "Pipeline", text: update.title, truncated: false }; + } if (update.kind === "agent_text" || update.kind === "agent_thought") { const preview = textPreview(updates, index, update.kind, active, maxChars); if (preview) return preview; diff --git a/desktop/src/lib/snooze.test.ts b/desktop/src/lib/snooze.test.ts index 05bb8b1..e2194fc 100644 --- a/desktop/src/lib/snooze.test.ts +++ b/desktop/src/lib/snooze.test.ts @@ -8,13 +8,7 @@ import { snoozeTomorrowMorning, } from "./snooze"; -function localMs( - year: number, - month: number, - day: number, - hours: number, - minutes: number, -): number { +function localMs(year: number, month: number, day: number, hours: number, minutes: number): number { return new Date(year, month, day, hours, minutes, 0, 0).getTime(); } diff --git a/desktop/src/lib/taskGroups.ts b/desktop/src/lib/taskGroups.ts index 4983efc..2f550cc 100644 --- a/desktop/src/lib/taskGroups.ts +++ b/desktop/src/lib/taskGroups.ts @@ -66,7 +66,9 @@ export function taskNeedsAttention(task: TaskInfo): boolean { const waiting = task.workflowRun?.waiting ?? null; return ( (!!waiting && waiting.kind !== "paused") || - task.status === "needs_review" || task.status === "blocked" || task.status === "interrupted" + task.status === "needs_review" || + task.status === "blocked" || + task.status === "interrupted" ); } diff --git a/desktop/src/lib/terminalWorkspace.ts b/desktop/src/lib/terminalWorkspace.ts index 3f2629e..9967865 100644 --- a/desktop/src/lib/terminalWorkspace.ts +++ b/desktop/src/lib/terminalWorkspace.ts @@ -155,7 +155,12 @@ export class TerminalWorkspace { return label; } - private attachTerminal(info: { id: string; project: string; command: string; startedAt: number }) { + private attachTerminal(info: { + id: string; + project: string; + command: string; + startedAt: number; + }) { if (this.entries.has(info.id)) return; if (this.exitedTombstones.has(info.id)) return; const controller = new TerminalController({ terminalId: info.id, project: info.project }); @@ -183,7 +188,10 @@ export class TerminalWorkspace { private getTerminalSignature(): string { const snap = daemon.getState().snapshot; const projectTerminals = snap.terminals.filter((t) => t.project === this.project); - return projectTerminals.map((t) => t.id).sort().join(","); + return projectTerminals + .map((t) => t.id) + .sort() + .join(","); } private reconcileFromSnapshot() { diff --git a/desktop/src/lib/updater.test.ts b/desktop/src/lib/updater.test.ts index 58460d0..87bc896 100644 --- a/desktop/src/lib/updater.test.ts +++ b/desktop/src/lib/updater.test.ts @@ -35,9 +35,7 @@ describe("DesktopUpdater", () => { }); it("explains that the update feed is unavailable before the first desktop release", async () => { - check.mockRejectedValueOnce( - new Error("Could not fetch a valid release JSON from the remote"), - ); + check.mockRejectedValueOnce(new Error("Could not fetch a valid release JSON from the remote")); const updater = new DesktopUpdater(); await updater.check(); diff --git a/desktop/src/lib/workflow.ts b/desktop/src/lib/workflow.ts index b9d8398..51bb533 100644 --- a/desktop/src/lib/workflow.ts +++ b/desktop/src/lib/workflow.ts @@ -14,5 +14,9 @@ export function workflowStageLabel(stage: WorkflowRunInfo["stage"]): string { return "done"; case "failed": return "failed"; + default: + // A newer daemon may report a stage this build does not know; show the + // raw value rather than silently rendering nothing. + return stage; } } diff --git a/desktop/src/protocol.ts b/desktop/src/protocol.ts index 7b8716f..7849cf4 100644 --- a/desktop/src/protocol.ts +++ b/desktop/src/protocol.ts @@ -290,6 +290,8 @@ export interface WorkflowRunInfo { verdict?: WorkflowVerdict | null; /** Set while the pipeline waits for the user. */ waiting?: WorkflowWaiting | null; + /** A pause is queued and takes effect when the running stage finishes. */ + pauseRequested?: boolean; } export type WorkflowStage = "plan" | "implement" | "review" | "fix" | "done" | "failed"; diff --git a/desktop/src/views/Board.tsx b/desktop/src/views/Board.tsx index a3ad8b4..2de2ebc 100644 --- a/desktop/src/views/Board.tsx +++ b/desktop/src/views/Board.tsx @@ -27,7 +27,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { workflowStageLabel } from "@/lib/workflow"; import { elapsed } from "@/lib/status"; import type { BoardLifecycleFilter, TaskTree } from "@/lib/taskGroups"; import { @@ -43,6 +42,7 @@ import { } from "@/lib/taskGroups"; import { taskLabel } from "@/lib/taskLabel"; import { cn } from "@/lib/utils"; +import { workflowStageLabel } from "@/lib/workflow"; import type { OrchNodeInfo, Snapshot, TaskInfo, TaskStatus } from "../protocol"; @@ -608,7 +608,10 @@ function TaskCard({ {hasAccordion && expanded && (
{nodes.map((node) => ( - + // `node.id` is a human label and repeats when a stage re-runs + // (restart-resume, or a same-session review round), so it is not + // a key on its own. + ))}
)} @@ -620,7 +623,12 @@ function NodeRow({ node }: { node: OrchNodeInfo }) { return (
- {node.kind} + + {node.id || node.kind} + {node.taskId && ( {node.taskId} @@ -734,10 +742,8 @@ function WorkflowBadge({ task }: { task: TaskInfo }) { 0 ? ` — round ${run.round}/${run.maxRounds}` : ""}`} className={cn( - "inline-flex shrink-0 items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium", - needsUser - ? "bg-amber-500/12 text-amber-600 dark:text-amber-400" - : "bg-primary/10 text-primary", + "inline-flex min-w-0 max-w-32 items-center gap-1 truncate rounded-full px-1.5 py-0.5 text-[10px] font-medium", + needsUser ? "bg-warn/12 text-warn" : "bg-primary/10 text-primary", )} > diff --git a/desktop/src/views/MissionControl.test.ts b/desktop/src/views/MissionControl.test.ts index a71bd07..638e5e9 100644 --- a/desktop/src/views/MissionControl.test.ts +++ b/desktop/src/views/MissionControl.test.ts @@ -200,8 +200,9 @@ describe("workflow conversation events", () => { expect(screen.getByText("Implement completed")).toBeInTheDocument(); expect(screen.getByText(/Implemented the parser/)).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Open implement agent session" })) - .toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Open implement agent session" }), + ).toBeInTheDocument(); }); }); diff --git a/desktop/src/views/MissionControl.tsx b/desktop/src/views/MissionControl.tsx index 77bf2f6..e8d3ed7 100644 --- a/desktop/src/views/MissionControl.tsx +++ b/desktop/src/views/MissionControl.tsx @@ -58,9 +58,11 @@ import { BufferedMarkdown, CollapsibleMarkdown, Markdown } from "../components/M import { StatusBadge } from "../components/StatusBadge"; import { TaskAgentSwitcher } from "../components/TaskAgentSwitcher"; import { ThinkingBlock } from "../components/ThinkingBlock"; +import { WorkflowControls } from "../components/WorkflowControls"; import { WorkflowEventLine } from "../components/WorkflowEventLine"; import type { DaemonState } from "../daemon"; import { daemon } from "../daemon"; +import { useWorkflowSend } from "../hooks/useWorkflowSend"; import type { CommandInfo, EditHunk, ProjectFile, SessionUpdate, TaskInfo } from "../protocol"; import { daemonQuery } from "../query"; import { useUi } from "../store/ui"; @@ -294,6 +296,8 @@ function FocusPane({ const capability = [...updates].reverse().find((update) => update.kind === "prompt_capabilities"); const imageSupported = capability?.kind === "prompt_capabilities" ? capability.image : false; const activity = sessionActivity(task, stream); + const workflowSend = useWorkflowSend(task); + const openTask = useUi((s) => s.openTask); return ( ))} @@ -436,6 +441,7 @@ function FocusPane({
+ {task.workflowRun && } { + // A workflow parent has no session — route to the pipeline instead. + if (await workflowSend.send(submission)) return; await daemon.request("session.prompt", { task_id: task.id, ...submission }); }} toolbar={ @@ -558,11 +566,14 @@ function PinnedStreamLine({ compact, taskId, resolved, + onOpenTask, }: { update: SessionUpdate; compact?: boolean; taskId?: string; resolved?: Record; + /** Lets a workflow stage card in a tile open that stage's session. */ + onOpenTask?: (id: string) => void; }) { if (update.kind === "agent_text" || update.kind === "agent_thought") { return ( @@ -576,10 +587,15 @@ function PinnedStreamLine({
); } - if (update.kind === "user_message") { - return ; - } - return ; + return ( + + ); } /** Shared renderer for one session-stream update. `compact` = focus pane @@ -784,9 +800,7 @@ export function StreamLine({ ); case "workflow_event": - return ( - - ); + return ; case "agent_thought": return compact ? ( New task
- One task = one agent session. The agent starts working immediately. + {selectedWorkflow + ? `One task = the ${selectedWorkflow.name} pipeline. Each stage runs as its own agent session.` + : "One task = one agent session. The agent starts working immediately."}