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/wide-pipelines-arrive.md b/.changeset/wide-pipelines-arrive.md
new file mode 100644
index 0000000..b3a9692
--- /dev/null
+++ b/.changeset/wide-pipelines-arrive.md
@@ -0,0 +1,31 @@
+---
+"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.
+
+Reviewers can pin each finding to a line and a short code excerpt, so the
+repair stage goes straight to the right place instead of searching, and the
+summary a stage hands to the next one is its closing message rather than the
+whole turn's tool narration.
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/CLAUDE.md b/CLAUDE.md
index df13a84..93def38 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -12,7 +12,7 @@ Workspace orchestrator with TUI and desktop interfaces. Manages multiple dev pro
- **PTY:** `portable-pty` — real TTY for spawned agents
- **Terminal emulation:** `vt100` crate — parses ANSI into screen buffer
- **CLI:** `clap` derive API
-- **Config:** `.workspace.yaml` per project (serde_yaml), registry in `~/.warpforge/projects.json`
+- **Config:** `.warpforge/workspace.yaml` per project (serde_yaml; legacy root-level `.warpforge.yaml` / `.wf.yaml` / `.workspace.yaml` still load), registry in `~/.warpforge/projects.json`
- **Port isolation:** each project gets 100-port range (4000+), `${svc.port}` interpolation in env vars
- **Daemon IPC:** WebSocket JSON-RPC (port 61814)
@@ -40,7 +40,8 @@ main.rs — CLI entry (clap), subcommands: add, remove, list, ui
app.rs — TUI event loop (tokio::select), AppState, InputMode (Navigate/Terminal), key handling
agent.rs — AgentManager: PTY spawn via portable-pty, vt100 parser, input/output channels
service.rs — ServiceManager: sh -c spawn, stdout/stderr log capture, process-group kill, port allocation
-config.rs — .workspace.yaml parsing + auto-detect (package.json scripts, docker-compose)
+config.rs — workspace config parsing + auto-detect (package.json scripts, docker-compose)
+workflow_config.rs — .warpforge/workflows/*.yaml templates: schema, validation, prompt rendering
registry.rs — ~/.warpforge/projects.json CRUD
ports.rs — Port range allocation (4000+), ${svc.port} env interpolation
tui/
@@ -54,6 +55,9 @@ tui/
- WebSocket server (port 61814) that desktop/TUI connect to
- Manages agents, services, port-forwards, tasks, git operations
- Key files: server.rs, actor.rs, task.rs, agents.rs, diff.rs
+- **workflow.rs** — deterministic workflow pipeline: run state, stage prompts,
+ agent protocols. Actor glue is the workflow block at the end of actor.rs.
+ Templates are parsed by `src/workflow_config.rs`; see `docs/adr/0001`.
**Desktop source (`desktop/src/`):**
```
@@ -112,6 +116,10 @@ lib/ — Utilities (38 files: sessionActivity, sessionTiming, e
- **Agent setup:** detect/install agents (Claude, Codex), bootstrap wizard
- **UI state:** Zustand store with localStorage persistence (panels, toggles, view)
- **Orchestration:** planner → workers → reviewers pipeline (daemon-driven)
+- **Workflows:** pick a configured pipeline (`.warpforge/workflows/*.yaml`) in
+ New Task; the daemon runs `plan? → implement → review ⇄ fix` as child tasks,
+ asks you at barriers (stage question, review limit), and supports
+ pause/resume. See `docs/adr/0001-workflow-pipelines.md` before changing it.
### Known Issues / TODO
@@ -123,6 +131,11 @@ lib/ — Utilities (38 files: sessionActivity, sessionTiming, e
### Key Design Decisions
+**Read `docs/adr/` before changing a subsystem it covers.** Those records hold
+the rejected alternatives and the invariants that are not visible in the code —
+each entry's *Invariants* section lists mistakes already made once. Add a new
+numbered record when you make a decision a future reader could not infer.
+
- **Ratatui + vt100** for terminal-in-terminal: native cell-by-cell rendering with zero translation layer. Previous TypeScript/OpenTUI attempt couldn't render ANSI properly.
- **vt100 + TerminalPane:** Agent PTY output → `vt100::Parser::process()` → `Screen` → iterate cells with colors/attributes → write directly to ratatui `Buffer`. Cursor rendered via `Modifier::REVERSED`.
- **portable-pty:** Cross-platform PTY. Reader/writer in `spawn_blocking` tasks, input via `mpsc::unbounded_channel`.
diff --git a/crates/warpforge-protocol/src/lib.rs b/crates/warpforge-protocol/src/lib.rs
index 7d4189d..8348616 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 },
@@ -432,6 +439,50 @@ 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 },
+ /// 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
/// create a config-gen task. Returns `{ taskId }`.
@@ -746,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")]
@@ -789,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 {
@@ -806,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,
},
@@ -1303,6 +1403,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)]
@@ -1315,6 +1417,125 @@ 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,
+}
+
+/// 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,
+ /// 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.
+#[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")]
@@ -1386,6 +1607,7 @@ mod tests {
attachments: vec![],
default_model: Some("opus".into()),
config_overrides: Default::default(),
+ workflow: None,
},
};
let json = serde_json::to_value(&req).unwrap();
@@ -1476,6 +1698,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
new file mode 100644
index 0000000..1425e39
--- /dev/null
+++ b/desktop/src/components/ChatComposer.test.tsx
@@ -0,0 +1,134 @@
+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 and points at the stages", () => {
+ renderComposer(task(run()));
+ expect(screen.getByRole("textbox")).toBeDisabled();
+ expect(screen.getByRole("textbox")).toHaveAttribute(
+ "placeholder",
+ expect.stringContaining("open a stage above"),
+ );
+ });
+
+ 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()));
+ expect(screen.queryByRole("button", { name: /^stop$/i })).not.toBeInTheDocument();
+ });
+
+ 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 }));
+ 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 2c10122..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,18 +23,23 @@ export const ChatComposer = memo(
{ commands, contextUsage, files, filesLoading, imageSupported, onBeforeSend, task },
ref,
) {
+ const workflow = useWorkflowSend(task);
+
const onSend = useCallback(
async (submission: PromptSubmission) => {
onBeforeSend();
+ if (await workflow.send(submission)) return;
await daemon.request("session.prompt", { task_id: task.id, ...submission });
},
- [onBeforeSend, task.id],
+ [onBeforeSend, task.id, workflow],
);
- const onCancel = useCallback(
- () => void daemon.request("task.cancel", { task_id: task.id }),
- [task.id],
- );
+ // 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 isRunning = task.status === "running" || task.status === "queued";
@@ -45,9 +51,10 @@ export const ChatComposer = memo(
files={files}
filesLoading={filesLoading}
imageSupported={imageSupported}
- disabled={task.status === "done"}
+ disabled={task.status === "done" || workflow.disabled}
onSend={onSend}
- onCancel={isRunning ? onCancel : undefined}
+ onCancel={isRunning && !workflow.isWorkflow ? onCancel : undefined}
+ placeholder={workflow.placeholder}
toolbar={
task.configOptions && task.configOptions.length > 0 ? (
diff --git a/desktop/src/components/ChatTranscript.tsx b/desktop/src/components/ChatTranscript.tsx
index ab3d766..113210f 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 = {
@@ -181,6 +182,7 @@ const TranscriptRow = memo(function TranscriptRow({
resolveFilePath={resolveFilePath}
onOpenFile={onOpenFile}
onOpenFileDiff={onOpenFileDiff}
+ onOpenTask={onOpenTask}
project={project}
/>
{messageText && (
@@ -486,6 +488,7 @@ export function ChatTranscript({
)}
+ {task.workflowRun && }
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 (
sendActionRef.current?.()}
+ onClick={stopAction ? onStop : () => sendActionRef.current?.()}
disabled={disabled}
>
- {stopping ? : }
+ {stopping ? (
+
+ ) : stopAction ? (
+
+ ) : (
+
+ )}
);
});
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 4fa2898..ff62495 100644
--- a/desktop/src/components/TaskAgentSwitcher.test.tsx
+++ b/desktop/src/components/TaskAgentSwitcher.test.tsx
@@ -60,4 +60,48 @@ 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..a09712d 100644
--- a/desktop/src/components/TaskAgentSwitcher.tsx
+++ b/desktop/src/components/TaskAgentSwitcher.tsx
@@ -28,7 +28,25 @@ 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 +66,16 @@ 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 +83,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/TaskComposeBar.test.tsx b/desktop/src/components/TaskComposeBar.test.tsx
new file mode 100644
index 0000000..8c57ae4
--- /dev/null
+++ b/desktop/src/components/TaskComposeBar.test.tsx
@@ -0,0 +1,124 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { WorkflowMeta } from "../protocol";
+import { TaskComposeBar } from "./TaskComposeBar";
+
+const onWorkflowChange = vi.fn<(v: string | null) => 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: /implement → review×2 → fix/ }));
+ 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 review loop to this project/i }));
+ expect(onEjectWorkflow).toHaveBeenCalledWith("review-loop");
+ expect(onWorkflowChange).not.toHaveBeenCalled();
+ });
+
+ it("summarizes the selected workflow and labels the agent as the per-stage default", () => {
+ renderBar({ workflow: "review-loop" });
+ expect(screen.getByText(/Stages: implement → review×2 → fix/)).toBeInTheDocument();
+ expect(screen.getByText(/up to 2 review rounds/)).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", () => {
+ 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..a23b064 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 ? "Lead agent" : workflow ? "Default agent" : "Agent"}
{agentChoices.map((a) => (
onAgentChange(a.id)}>
@@ -101,12 +130,34 @@ 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 ?? []).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."}
+
+ )}
);
}
@@ -180,3 +231,116 @@ 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 (
+
+
+
+
+
+ {selected ? selected.name : "Workflow"}
+
+
+
+
+
+ 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)}
+ aria-label={`Copy ${w.name} to this project`}
+ >
+
+
+ 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..da873ec
--- /dev/null
+++ b/desktop/src/components/WorkflowControls.test.tsx
@@ -0,0 +1,166 @@
+import { render, screen, waitFor } 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 () => {});
+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 [])),
+ },
+}));
+
+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();
+ workflowDecide.mockImplementation(async () => {});
+ });
+
+ 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();
+ expect(screen.getByText("Review limit reached")).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();
+
+ 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(finish);
+ expect(workflowDecide).toHaveBeenCalledWith("t_1", "finish");
+
+ 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("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();
+ 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();
+ });
+
+ 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
new file mode 100644
index 0000000..a395ee1
--- /dev/null
+++ b/desktop/src/components/WorkflowControls.tsx
@@ -0,0 +1,262 @@
+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";
+
+/**
+ * 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 [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) => {
+ setBusyAction(label);
+ try {
+ await fn();
+ } catch (e) {
+ toast.error(`Could not ${label}`, {
+ description: e instanceof Error ? e.message : String(e),
+ });
+ } finally {
+ setBusyAction(null);
+ }
+ };
+
+ return (
+
+
+
+
+ {!finished && (waiting === null || waiting.kind === "paused") && (
+ <>
+ {waiting?.kind === "paused" ? (
+ void act("resume", () => daemon.workflowResume(task.id))}
+ >
+ {busyAction === "resume" ? (
+
+ ) : (
+
+ )}
+ {busyAction === "resume" ? "Resuming…" : "Resume"}
+
+ ) : (
+ void act("pause", () => daemon.workflowPause(task.id))}
+ >
+ {busyAction === "pause" ? (
+
+ ) : (
+
+ )}
+ {busyAction === "pause" || run.pauseRequested ? "Pausing…" : "Pause"}
+
+ )}
+ >
+ )}
+ {!finished && waiting?.kind !== "limit" && (
+
+ void act("stop the workflow", async () => {
+ await daemon.request("task.cancel", { task_id: task.id });
+ })
+ }
+ >
+ {busyAction === "stop the workflow" ? (
+
+ ) : (
+
+ )}
+ {busyAction === "stop the workflow" ? "Stopping…" : "Stop"}
+
+ )}
+
+
+
+ {waiting?.kind === "limit" && (
+
+ )}
+
+ {waiting?.kind === "paused" && (
+
+ Paused before the {workflowStageLabel(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,
+ busyAction,
+ act,
+}: {
+ task: TaskInfo;
+ summary: string;
+ busyAction: string | null;
+ act: (label: string, fn: () => Promise) => Promise;
+}) {
+ const busy = busyAction !== null;
+ return (
+
+
+
+
+
Review limit reached
+
+ Reviewers still request changes{summary ? ` — ${summary}` : ""}.
+
+
+ Continue the fix → review loop, finish with the current changes, or stop the workflow.
+
+
+
+
+
+
+ void act("add one review round", () =>
+ daemon.workflowDecide(task.id, "extend", { rounds: 1 }),
+ )
+ }
+ >
+ {busyAction === "add one review round" ? : }
+ {busyAction === "add one review round" ? "Continuing…" : "1 more round"}
+
+
+ void act("add two review rounds", () =>
+ daemon.workflowDecide(task.id, "extend", { rounds: 2 }),
+ )
+ }
+ >
+ {busyAction === "add two review rounds" ? : }
+ {busyAction === "add two review rounds" ? "Continuing…" : "2 more rounds"}
+
+
+ void act("finish the workflow", () => daemon.workflowDecide(task.id, "finish"))
+ }
+ >
+ {busyAction === "finish the workflow" ? (
+
+ ) : (
+
+ )}
+ {busyAction === "finish the workflow" ? "Finishing…" : "Finish for review"}
+
+
+ void act("stop the workflow", () => daemon.workflowDecide(task.id, "stop"))
+ }
+ >
+ {busyAction === "stop the workflow" ? (
+
+ ) : (
+
+ )}
+ {busyAction === "stop the workflow" ? "Stopping…" : "Stop"}
+
+
+
+
+ These buttons continue without guidance. To add guidance, type it below and press Send
+ instead — that runs one more round with your note.
+
+
+ );
+}
+
+function StageIndicator({ run }: { run: WorkflowRunInfo }) {
+ const waiting = run.waiting ?? null;
+ return (
+
+
+ {run.workflowName}
+
+ ·
+
+ {waiting?.kind === "paused"
+ ? `paused before ${workflowStageLabel(run.stage)}`
+ : workflowStageLabel(run.stage)}
+
+ {run.round > 0 && run.stage !== "done" && run.stage !== "failed" && (
+
+ round {run.round}/{run.maxRounds}
+
+ )}
+ {run.verdict && (
+
+ {run.verdict === "approve" ? "approved" : "changes requested"}
+
+ )}
+
+ );
+}
diff --git a/desktop/src/components/WorkflowEventLine.tsx b/desktop/src/components/WorkflowEventLine.tsx
new file mode 100644
index 0000000..d36991b
--- /dev/null
+++ b/desktop/src/components/WorkflowEventLine.tsx
@@ -0,0 +1,118 @@
+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) => (
+
onOpenTask?.(agent.taskId)}
+ className="flex max-w-44 shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-background/50 hover:text-foreground disabled:pointer-events-none"
+ aria-label={`Open ${agent.label} agent session`}
+ >
+
+
+ ))}
+
+
+ {showAgentCards && update.agents.length > 0 && (
+
+ {update.agents.map((agent) => (
+
onOpenTask?.(agent.taskId)}
+ 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`}
+ >
+
+
+ {agent.label}
+
+
+ {agent.model && (
+
+ {agent.model}
+
+ )}
+
+
+ {onOpenTask && (
+
+ )}
+
+ ))}
+
+ )}
+
+ {update.detail &&
+ (compact ? (
+ {update.detail}
+ ) : (
+
+ {update.detail}
+
+ ))}
+
+ );
+}
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/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/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/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.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..2f550cc 100644
--- a/desktop/src/lib/taskGroups.ts
+++ b/desktop/src/lib/taskGroups.ts
@@ -63,8 +63,12 @@ export function taskLifecycle(task: TaskInfo, nowSeconds: number): TaskLifecycle
}
export function taskNeedsAttention(task: TaskInfo): boolean {
+ const waiting = task.workflowRun?.waiting ?? null;
return (
- task.status === "needs_review" || task.status === "blocked" || task.status === "interrupted"
+ (!!waiting && waiting.kind !== "paused") ||
+ 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
new file mode 100644
index 0000000..51bb533
--- /dev/null
+++ b/desktop/src/lib/workflow.ts
@@ -0,0 +1,22 @@
+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";
+ 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 7024e18..7849cf4 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). */
@@ -237,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";
@@ -256,6 +258,58 @@ 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;
+ /** A pause is queued and takes effect when the running stage finishes. */
+ pauseRequested?: boolean;
+}
+
+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 {
@@ -287,10 +341,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 b08055a..2de2ebc 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";
@@ -41,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";
@@ -562,6 +564,7 @@ function TaskCard({
+
{task.project}
{task.worktree &&
}
{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.
+
))}
)}
@@ -617,7 +623,12 @@ function NodeRow({ node }: { node: OrchNodeInfo }) {
return (
-
{node.kind}
+
+ {node.id || node.kind}
+
{node.taskId && (
{node.taskId}
@@ -709,6 +720,43 @@ function LifecycleBadge({ task, nowSeconds }: { task: TaskInfo; nowSeconds: numb
);
}
+/**
+ * Pipeline stage pill for workflow parent tasks. Amber while the pipeline
+ * waits on the user (a question or an exhausted review limit), muted
+ * otherwise — the surrounding StatusBadge already carries run/done state.
+ */
+function WorkflowBadge({ task }: { task: TaskInfo }) {
+ const run = task.workflowRun;
+ if (!run || run.stage === "done" || run.stage === "failed") return null;
+ const waiting = run.waiting ?? null;
+ const needsUser = !!waiting && waiting.kind !== "paused";
+ const label =
+ waiting?.kind === "limit"
+ ? "review limit"
+ : waiting?.kind === "question"
+ ? "needs answer"
+ : waiting?.kind === "paused"
+ ? "paused"
+ : workflowStageLabel(run.stage);
+ return (
+
0 ? ` — round ${run.round}/${run.maxRounds}` : ""}`}
+ className={cn(
+ "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",
+ )}
+ >
+
+ {label}
+ {run.round > 0 && !needsUser && (
+
+ {run.round}/{run.maxRounds}
+
+ )}
+
+ );
+}
+
function Empty() {
return
No tasks
;
}
diff --git a/desktop/src/views/MissionControl.test.ts b/desktop/src/views/MissionControl.test.ts
index 84e6285..638e5e9 100644
--- a/desktop/src/views/MissionControl.test.ts
+++ b/desktop/src/views/MissionControl.test.ts
@@ -146,6 +146,66 @@ 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..e8d3ed7 100644
--- a/desktop/src/views/MissionControl.tsx
+++ b/desktop/src/views/MissionControl.tsx
@@ -58,8 +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";
@@ -293,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 (
))}
@@ -435,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={
@@ -557,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 (
@@ -575,10 +587,15 @@ function PinnedStreamLine({
);
}
- if (update.kind === "user_message") {
- return
;
- }
- return
;
+ return (
+
+ );
}
/** Shared renderer for one session-stream update. `compact` = focus pane
@@ -703,6 +720,7 @@ export function StreamLine({
resolveFilePath,
onOpenFile,
onOpenFileDiff,
+ onOpenTask,
project,
thinkingActive,
textStreaming,
@@ -716,6 +734,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 +799,8 @@ export function StreamLine({
{update.text}
);
+ case "workflow_event":
+ return
;
case "agent_thought":
return compact ? (
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,25 @@ 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: e instanceof Error ? e.message : String(e),
+ });
+ }
+ };
const close = useCallback(() => onOpenChange(false), [onOpenChange]);
@@ -125,24 +166,37 @@ 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: e instanceof Error ? e.message : 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),
@@ -180,7 +234,9 @@ export default function NewTaskDialog({
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."}
@@ -234,7 +294,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 +356,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"}
diff --git a/desktop/src/views/TaskDetail.tsx b/desktop/src/views/TaskDetail.tsx
index a178d2d..c5f1586 100644
--- a/desktop/src/views/TaskDetail.tsx
+++ b/desktop/src/views/TaskDetail.tsx
@@ -372,7 +372,7 @@ export default function TaskDetail({ task, snapshot, onClose, onOpenTask, onOpen
Loading changes…
)
) : rightPanel === "subtasks" ? (
-
+
) : (
(null);
- const hasLiveResources =
- liveCounts.services + liveCounts.portforwards + liveCounts.terminals > 0;
+ const hasLiveResources = liveCounts.services + liveCounts.portforwards + liveCounts.terminals > 0;
const actorRejectedRemoval =
error?.startsWith("conflict:") === true || error?.startsWith("internal:") === true;
@@ -85,12 +84,7 @@ export function RemoveProjectDialog({ project, liveCounts, onCancel, onConfirm }
{countLabel(liveCounts.services, "running or starting service")}
)}
{liveCounts.portforwards > 0 && (
-
- {countLabel(
- liveCounts.portforwards,
- "active or starting port-forward",
- )}
-
+ {countLabel(liveCounts.portforwards, "active or starting port-forward")}
)}
{liveCounts.terminals > 0 && (
{countLabel(liveCounts.terminals, "live terminal")}
diff --git a/desktop/src/views/task-detail/SubtasksRail.tsx b/desktop/src/views/task-detail/SubtasksRail.tsx
index a8017d8..04ced5f 100644
--- a/desktop/src/views/task-detail/SubtasksRail.tsx
+++ b/desktop/src/views/task-detail/SubtasksRail.tsx
@@ -6,20 +6,50 @@ import { AgentBadge } from "../../components/AgentBadge";
import { StatusBadge } from "../../components/StatusBadge";
import type { OrchNodeInfo, TaskInfo } from "../../protocol";
-function SubtaskRow({ node }: { node: OrchNodeInfo }) {
- return (
-
+function SubtaskRow({
+ node,
+ onOpenTask,
+}: {
+ node: OrchNodeInfo;
+ onOpenTask: (id: string) => void;
+}) {
+ const content = (
+ <>
-
{node.kind}
+
+ {node.id || node.kind}
+
{node.taskId && (
{node.taskId}
)}
+ >
+ );
+ return node.taskId ? (
+
onOpenTask(node.taskId!)}
+ className="flex w-full items-center gap-2 rounded bg-secondary/30 px-2 py-1.5 text-left text-xs transition-colors hover:bg-secondary/60"
+ >
+ {content}
+
+ ) : (
+
+ {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 +68,12 @@ export function SubtasksRail({ task }: { task: TaskInfo }) {
{nodes.map((node) => (
-
+ // Labels repeat when a stage re-runs, so index-qualify the key.
+
))}
diff --git a/docs/adr/0001-workflow-pipelines.md b/docs/adr/0001-workflow-pipelines.md
new file mode 100644
index 0000000..a8385b8
--- /dev/null
+++ b/docs/adr/0001-workflow-pipelines.md
@@ -0,0 +1,125 @@
+# 0001 — Workflow pipelines: deterministic engine, project-configured
+
+**Status:** accepted (2026-07-30) · introduced in `ephor/warpforge#21`
+
+## Context
+
+"Process" used to mean one **Orchestrator** toggle: a hardcoded prompt plus
+three MCP tools handed to an LLM, which then decided for itself what to
+delegate. There were no roles, no review, no iteration limit, and nothing a
+user could configure. Making the process better meant editing a prompt string
+in the binary.
+
+## Decisions
+
+**The engine is deterministic. The model never decides what happens next.**
+Transitions live in `daemon/workflow.rs` + the workflow block of
+`daemon/actor.rs`. *Rejected:* a manager agent driving the pipeline through
+tools — it reintroduces exactly the "process is whatever the model felt like"
+problem, and its failures are unreproducible.
+
+**A workflow is a YAML file in the project** (`.warpforge/workflows/*.yaml`),
+not UI state or a DB row. It lives with the code it reviews, is diffable, and
+travels with the repo for the whole team. Built-ins ship in the binary and can
+be copied into a project to edit.
+
+**The pipeline shape is fixed** (`plan? → implement → review ⇄ fix`) and the
+YAML configures those stages. *Rejected for the first iteration:* an arbitrary
+stage DAG with activation conditions and gate expressions. The fixed shape
+covers the real cases, and a DAG can be added later without changing the file
+format for existing workflows.
+
+**Each stage is a normal child task with its own agent session.** It appears on
+the board, its chat can be opened, and a human can intervene in it. *Rejected:*
+one long session with role-switching prompts — the roles then share context and
+a reviewer inherits the implementer's blind spots.
+
+**The parent task has no agent session.** It is the pipeline's record: a
+timeline of stages, plus the place where the engine asks the user something.
+Consequence: the parent's composer can only be an answer box at a barrier, and
+**everything that routes composer input must go through `useWorkflowSend`** —
+prompting the parent directly fails with a raw daemon error.
+
+**The user is asked at structured decision points, not in free chat.** A stage
+can suspend the run with a `need_user_input` marker; exhausting the review
+rounds asks extend/finish/stop. Control is by button, free text is payload with
+one unambiguous addressee. *Rejected:* interpreting free text as commands —
+that needs an LLM in the control path, which decision 1 rules out.
+
+**Repeat review rounds continue in the same reviewer's session** by default
+(`review.reask`). The reviewer remembers its own findings and can verify each
+one, and it costs a delta instead of a full re-read. The cost is anchoring
+bias, mitigated in the prompt ("defending your earlier verdict is not a goal")
+and by an explicit regression check. A dead session falls back to a fresh one
+carrying the previous findings.
+
+**Agents talk to the engine through fenced JSON** (verdict, `need_user_input`),
+appended to every stage prompt including custom ones. Machine-readable output
+from a text model is the weakest link in the design, so every parse path has a
+fallback rather than a hard failure.
+
+**A finished pipeline commits nothing.** It lands in `NeedsReview` for a human.
+*Rejected:* an `on_success: commit` option — it makes an unattended pipeline
+able to write history.
+
+**Pause is soft, at stage boundaries.** The running stage finishes its turn and
+the next one does not start. This is what makes pause survive a daemon restart:
+no barrier state depends on a live session. *Rejected for now:* interrupting a
+turn mid-flight.
+
+## Invariants
+
+Break one of these and the failure is quiet. Each was a real bug found in
+review.
+
+1. **`workflow_runs` take/put.** Methods `remove` the run and must re-insert on
+ *every* exit path, including early returns and error branches. A dropped run
+ leaves the parent stuck in "running" with no controls.
+2. **`AcpHandle::prompt` is not a liveness check.** Its channel belongs to the
+ driver task, which outlives the child process, so it returns `Ok` for a dead
+ agent and the prompt vanishes. Ask `is_alive()` before treating a follow-up
+ as delivered — otherwise the same-session and dead-session fallbacks are
+ unreachable exactly when they are needed.
+3. **A stage's output is its closing message** — the text after its last tool
+ call — not the whole turn. The full turn is only a parsing fallback. This
+ keeps tool narration out of reviewer prompts and stops a JSON block quoted
+ mid-turn from being read as the protocol payload.
+4. **`request_changes` must never finish the run as a success.** A reviewer that
+ asks for changes but produces no parseable findings has its prose salvaged
+ into one finding. The "only low-severity notes remain" shortcut is valid
+ *only* when findings were actually parsed and all were low.
+5. **A reviewer that cannot produce a verdict abstains.** Only "no reviewer
+ produced one" fails the run. Failing a complete implementation because one
+ agent wrote prose twice is not acceptable.
+6. **A stage whose session fails to start must fail the run.** `start_session`
+ reports failure by blocking the child and inserting no handle, so no
+ `TurnEnded` will ever arrive and nothing else will notice.
+7. **Stage sessions stay alive for the whole run** (same-session re-review needs
+ them) and must *all* be swept at finalize — `active_children` is not enough.
+8. **The round counter increments only when a new review round starts.**
+ Re-entering the same round after a restart must not re-count it, or the run
+ reports "round 3/2" and jumps straight to the limit decision.
+9. **Waits on a child's exit are bounded.** The daemon actor is single-threaded
+ and awaits handlers inline; an unkillable child would otherwise freeze every
+ project.
+10. **Everything persisted in a run snapshot carries `serde(default)`.** A field
+ that is required on load turns every in-flight pipeline unreadable after an
+ upgrade.
+
+## Consequences
+
+- Roughly 3–4× the tokens and wall-clock of a single-agent run, and stages are
+ serial. Worth it for review-worthy changes, not for trivial ones.
+- `max_rounds` counts **reviews**; a fix runs between them, so N rounds buy N−1
+ repair attempts (default 3 = two attempts).
+- Usefulness depends on how well agents follow the JSON protocols. The prompts
+ are load-bearing: treat them as code, not copy.
+- Reviewers see the working-copy diff, so a dirty tree or an agent that commits
+ its own work distorts what they review.
+
+## Out of scope, deliberately
+
+Finding ledger and cross-round dedup, deterministic build/lint/test gates
+(agents run their own checks), separate tester and final-acceptor roles, global
+`~/.warpforge/workflows/`, template versioning, auto-commit. The richer
+reference design these were pruned from is kept outside the repo.
diff --git a/docs/adr/README.md b/docs/adr/README.md
new file mode 100644
index 0000000..c4387e6
--- /dev/null
+++ b/docs/adr/README.md
@@ -0,0 +1,18 @@
+# Architecture decision records
+
+Decisions that are **not recoverable from the code**: what was chosen, what was
+rejected, and which invariants a future change must not break. If something can
+be learned by reading the code, it does not belong here — that duplication goes
+stale and then misleads.
+
+- One file per decision or per coherent cluster of decisions, numbered:
+ `NNNN-short-slug.md`.
+- Records are append-only. Don't rewrite a decision that changed — add a new
+ record and mark the old one `Superseded by NNNN`.
+- Keep each one short enough to read in full before touching the subsystem.
+ The **Invariants** section is the part that prevents regressions; put real
+ effort there and name the module it applies to.
+
+| ADR | Subject |
+| --- | --- |
+| [0001](0001-workflow-pipelines.md) | Workflow pipelines: deterministic engine, project-configured |
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/acp.rs b/src/daemon/acp.rs
index c6733c6..817b213 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,51 @@ 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_within(STOP_GRACE).await
+ }
+
+ /// False once the process monitor has reaped the ACP child. `prompt()` is
+ /// NOT a liveness test — its channel belongs to the driver task, which
+ /// outlives the child — so anything that must know whether an agent is
+ /// still there has to ask this.
+ pub fn is_alive(&self) -> bool {
+ matches!(*self.exit_rx.borrow(), ChildState::Running)
+ }
+
+ /// Wait for the process monitor's post-`child.wait()` notification, giving
+ /// up after `timeout`. The bound matters: callers await this inline in the
+ /// daemon actor, so an unkillable child (D-state on a stuck mount) would
+ /// otherwise freeze every project's RPCs and events, not just this task.
+ pub async fn wait_for_exit_within(&self, timeout: std::time::Duration) -> Result<(), String> {
+ match tokio::time::timeout(timeout, self.wait_for_exit()).await {
+ Ok(result) => result,
+ Err(_) => Err(format!(
+ "agent process did not exit within {}s of being killed",
+ timeout.as_secs()
+ )),
+ }
+ }
+
+ /// 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
}
@@ -190,6 +236,9 @@ impl FailureReporter {
}
}
+/// How long a caller waits for a killed ACP child to be reaped before giving
+/// up. Bounded so a stuck child cannot stall the single-threaded daemon actor.
+pub const STOP_GRACE: std::time::Duration = std::time::Duration::from_secs(10);
const STDERR_LINE_BYTES: usize = 512;
const STDERR_TOTAL_BYTES: usize = 4096;
@@ -759,10 +808,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 +1233,7 @@ pub fn spawn_acp_session(
Ok(AcpHandle {
cmd_tx,
+ exit_rx,
image_capability,
process,
run_id,
@@ -2142,9 +2194,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 e266775..f0d4437 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;
@@ -146,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")
@@ -569,6 +573,47 @@ 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,
+ include_runtime_context: bool,
+ config_overrides: std::collections::HashMap,
+ 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,
@@ -576,6 +621,7 @@ pub enum Command {
},
CancelTask {
id: String,
+ reply: oneshot::Sender>,
},
/// Archive a task (set status to Done, hide from live views).
ArchiveTask {
@@ -1015,6 +1061,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(),
@@ -1495,6 +1554,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 +1658,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 +1692,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 +1959,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 +2429,57 @@ impl Daemon {
config_overrides,
);
}
+ Command::CreateWorkflowTask {
+ project,
+ prompt,
+ agent,
+ tags,
+ worktree,
+ workflow,
+ attachments,
+ default_model,
+ include_runtime_context,
+ config_overrides,
+ reply,
+ } => {
+ let result = self
+ .workflow_create(
+ project,
+ prompt,
+ agent,
+ tags,
+ worktree,
+ workflow,
+ attachments,
+ default_model,
+ include_runtime_context,
+ config_overrides,
+ )
+ .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,
@@ -2681,19 +2798,40 @@ 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));
- }
+ 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
+ } else {
+ let stop_result = match self.sessions.remove(&id) {
+ Some(handle) => handle.cancel_and_wait().await,
+ None => Ok(()),
+ };
+ self.pending_permissions.cleanup_task(&id);
+ // A finished pipeline's parent keeps its terminal status:
+ // cancelling it must not rewrite NeedsReview back to Idle.
+ let finished_workflow = self
+ .workflow_runs
+ .get(&id)
+ .is_some_and(|run| !run.is_active());
+ if let Some(task) = self.tasks.get_mut(&id).filter(|_| !finished_workflow) {
+ 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;
+ 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) {
+ 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.
let child_ids: Vec = self
@@ -2722,6 +2860,14 @@ impl Daemon {
}
}
Command::DeleteTask { id } => {
+ if self.workflow_is_active(&id) {
+ let _ = 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 +2888,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 } => {
@@ -3261,7 +3409,7 @@ impl Daemon {
let entry =
crate::registry::add_project(path, name).map_err(|e| format!("registry: {e}"))?;
- // Generate .warpforge.yaml if none exists.
+ // Generate the workspace config if none exists.
let config_file = crate::config::find_config_file(std::path::Path::new(&entry.path));
if !config_file.exists() {
crate::config::generate_workspace_yaml(std::path::Path::new(&entry.path)).ok();
@@ -3662,9 +3810,16 @@ 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 } => {
+ // 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();
@@ -3777,6 +3932,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;
@@ -3785,24 +3941,40 @@ 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());
- // 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 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.
+ // The legacy orchestrator inbox path does not apply here.
+ let text = self.collect_stage_text(&task_id);
+ self.workflow_stage_finished(&task_id, success, text).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 +4001,19 @@ 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,
+ StageText {
+ closing: reason.clone(),
+ full: reason,
+ },
+ )
+ .await;
+ } else {
+ self.deliver_child_result(&task_id, false, reason);
+ }
}
}
}
@@ -3928,6 +4112,58 @@ 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 {
+ self.collect_stage_text(task_id).full
+ }
+
+ /// A finished stage's output, in the two shapes the pipeline needs.
+ ///
+ /// `closing` is the agent's final message — the text streamed after its
+ /// last tool call or file edit. That is what the stage prompts ask for
+ /// ("your final message should summarize what you did") and what a human
+ /// reads as the result, so it is what reviewers and fixers are handed.
+ /// `full` is every chunk of the turn, kept as a parsing fallback for an
+ /// agent that emits its protocol block before a trailing tool call.
+ fn collect_stage_text(&self, task_id: &str) -> StageText {
+ let Some(updates) = self
+ .store
+ .as_ref()
+ .and_then(|s| s.load_session_updates(task_id).ok())
+ else {
+ return StageText::default();
+ };
+ let mut full: Vec = Vec::new();
+ let mut closing: Vec = Vec::new();
+ for update in updates {
+ match update {
+ // A new user message starts a fresh turn.
+ wire::SessionUpdate::UserMessage { .. } => {
+ full.clear();
+ closing.clear();
+ }
+ wire::SessionUpdate::AgentText { text } => {
+ full.push(text.clone());
+ closing.push(text);
+ }
+ // Any work the agent does ends whatever it was narrating, so
+ // the closing message restarts after it.
+ wire::SessionUpdate::ToolCall { .. }
+ | wire::SessionUpdate::FileEdit { .. }
+ | wire::SessionUpdate::AgentThought { .. }
+ | wire::SessionUpdate::Plan { .. } => closing.clear(),
+ _ => {}
+ }
+ }
+ StageText {
+ closing: closing.join(""),
+ full: full.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 +4381,1451 @@ 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())
+ }
+
+ /// Deliver a follow-up into an existing stage session. Returns false when
+ /// the session is gone — checking `is_alive()` matters because
+ /// `AcpHandle::prompt` succeeds even for a dead child (its channel belongs
+ /// to the driver task, which outlives the process), and a prompt sent into
+ /// a corpse simply vanishes.
+ fn workflow_followup(&mut self, child_id: &str, text: String) -> bool {
+ let delivered = self
+ .sessions
+ .get(child_id)
+ .filter(|handle| handle.is_alive())
+ .map(|handle| {
+ handle
+ .prompt(super::prompt::PreparedPrompt {
+ content: vec![super::prompt::PromptContent::Text(text.clone())],
+ summaries: vec![],
+ has_images: false,
+ })
+ .is_ok()
+ })
+ .unwrap_or(false);
+ if delivered {
+ self.emit_session(
+ child_id,
+ wire::SessionUpdate::UserMessage {
+ text,
+ attachments: vec![],
+ },
+ );
+ }
+ delivered
+ }
+
+ /// 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::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();
+ 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);
+ }
+ }
+ }
+
+ /// 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)]
+ 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,
+ include_runtime_context: bool,
+ config_overrides: HashMap,
+ ) -> Result {
+ let path = self
+ .project_path(&project)
+ .ok_or_else(|| format!("unknown project '{project}'"))?;
+ if self.store.is_none() {
+ // Stage results are read back out of the persisted transcript, so
+ // without a store every stage would look like it produced nothing.
+ return Err(
+ "workflows need the local database, which failed to open — check \
+ ~/.warpforge and restart the daemon"
+ .to_string(),
+ );
+ }
+ let loaded =
+ crate::workflow_config::load_workflow(std::path::Path::new(&path), &workflow_id)
+ .ok_or_else(|| format!("unknown workflow `{workflow_id}`"))?;
+ let warnings = loaded.warnings.clone();
+ 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,
+ include_runtime_context,
+ config_overrides,
+ );
+ self.workflow_event(
+ &parent_id,
+ wire::WorkflowEventKind::WorkflowStarted,
+ format!("Workflow started: {}", run.spec.name),
+ Some({
+ let mut detail = format!(
+ "**Stages:** {} \n**Review limit:** {} round(s)",
+ run.spec.stage_summary().join(" → "),
+ run.effective_max_rounds(),
+ );
+ // Warnings are otherwise only visible as a picker tooltip, so a
+ // clamped limit or an ignored key would silently shape the run.
+ if !warnings.is_empty() {
+ detail.push_str("\n\n**Workflow file warnings:**\n");
+ for warning in &warnings {
+ detail.push_str(&format!("- {warning}\n"));
+ }
+ }
+ detail
+ }),
+ None,
+ Vec::new(),
+ wire::WorkflowEventTone::Info,
+ );
+ 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,
+ },
+ 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,
+ };
+ // 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());
+ // 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 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);
+ if self.workflow_followup(&prior_id, followup) {
+ // 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 spawned = self.workflow_spawn_child(
+ &run.project,
+ parent_id,
+ &agent,
+ model.clone(),
+ prompt,
+ worktree.clone(),
+ format!("review · {parent_title}"),
+ Vec::new(),
+ run.include_runtime_context,
+ run.config_overrides.clone(),
+ );
+ // A reviewer whose session never started is recorded as a
+ // failed node and excluded, exactly like one that dies
+ // mid-review — it must not sit in `review_pending` waiting
+ // for a TurnEnded that can never arrive.
+ let (child_id, started) = match spawned {
+ Ok(id) => (id, true),
+ Err(id) => (id, false),
+ };
+ run.record_stage(stage, &child_id, &agent, format!("{label}, {round_label}"));
+ if started {
+ run.review_pending.insert(child_id.clone(), index);
+ run.active_children.insert(child_id.clone(), stage);
+ } else {
+ run.set_record_status(&child_id, wire::OrchNodeStatus::Failed);
+ }
+ event_agents.push(wire::WorkflowEventAgent {
+ task_id: child_id,
+ label,
+ agent,
+ 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(detail),
+ Some(stage),
+ event_agents,
+ wire::WorkflowEventTone::Running,
+ );
+ }
+ _ => {
+ 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 spawned = self.workflow_spawn_child(
+ &run.project,
+ parent_id,
+ &agent,
+ model.clone(),
+ prompt,
+ worktree.clone(),
+ format!("{} · {parent_title}", stage.label()),
+ attachments,
+ run.include_runtime_context,
+ run.config_overrides.clone(),
+ );
+ let label = match stage {
+ StageKind::Fix => format!("{} (round {})", stage.label(), run.round),
+ _ => stage.label().to_string(),
+ };
+ let child_id = match spawned {
+ Ok(id) => {
+ run.active_children.insert(id.clone(), stage);
+ run.record_stage(stage, &id, &agent, label.clone());
+ id
+ }
+ Err(id) => {
+ // No session means no TurnEnded will ever arrive, so
+ // fail the pipeline here instead of hanging in
+ // "running" until the user cancels.
+ run.record_stage(stage, &id, &agent, label.clone());
+ run.set_record_status(&id, wire::OrchNodeStatus::Failed);
+ let reason = self
+ .tasks
+ .get(&id)
+ .and_then(|t| t.blocked_reason.clone())
+ .unwrap_or_else(|| {
+ "the agent session could not be started".to_string()
+ });
+ self.workflow_runs.insert(parent_id.to_string(), run);
+ let _ = self
+ .workflow_finalize(
+ parent_id,
+ WorkflowOutcome::Error(format!(
+ "stage {} could not start: {reason}",
+ stage.label()
+ )),
+ )
+ .await;
+ return;
+ }
+ };
+ self.workflow_event(
+ parent_id,
+ wire::WorkflowEventKind::StageStarted,
+ format!("{} started", stage.title()),
+ None,
+ Some(stage),
+ vec![wire::WorkflowEventAgent {
+ task_id: child_id,
+ label,
+ agent,
+ model,
+ }],
+ wire::WorkflowEventTone::Running,
+ );
+ }
+ }
+ 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)]
+ #[allow(clippy::result_large_err)]
+ fn workflow_spawn_child(
+ &mut self,
+ project: &str,
+ parent_id: &str,
+ agent: &str,
+ model: Option,
+ prompt: String,
+ worktree: Option,
+ title: String,
+ attachments: Vec,
+ include_runtime_context: bool,
+ config_overrides: HashMap,
+ ) -> Result {
+ 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,
+ include_runtime_context,
+ None,
+ attachments,
+ model,
+ config_overrides,
+ );
+ // `start_session` reports prompt-preparation and spawn failures by
+ // blocking the child task and inserting no handle. Without a session
+ // there is no TurnEnded to advance the pipeline, so the caller must
+ // learn about it here or the parent hangs in "running" forever.
+ if self.sessions.contains_key(&child_id) {
+ Ok(child_id)
+ } else {
+ Err(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, text: StageText) {
+ // Prefer the closing message: it is the agent's actual result, and
+ // reading it instead of the whole turn means a JSON block quoted
+ // mid-turn (while browsing a config file, say) cannot be mistaken for
+ // the protocol payload. Fall back to the full turn only when the
+ // payload genuinely is not in the closing message — an agent that
+ // emitted its block and then made one last tool call.
+ let closing_is_usable = !text.closing.trim().is_empty()
+ && (workflow::has_protocol_payload(&text.closing)
+ || !workflow::has_protocol_payload(&text.full));
+ let output = if closing_is_usable {
+ text.closing.clone()
+ } else {
+ text.full.clone()
+ };
+ 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_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,
+ wire::WorkflowEventKind::AgentOutput,
+ format!("{} needs your input", stage.title()),
+ Some(workflow::display_output(&output)),
+ Some(stage),
+ event_agent.into_iter().collect(),
+ wire::WorkflowEventTone::Warning,
+ );
+ 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);
+ 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.clone()),
+ _ => run.last_summary = Some(output.clone()),
+ }
+ self.workflow_event(
+ &parent_id,
+ wire::WorkflowEventKind::AgentOutput,
+ format!("{} completed", stage.title()),
+ Some(workflow::display_output(&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);
+ 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());
+ 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,
+ 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);
+ let _ = 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());
+ 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,
+ 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,
+ /// 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)) => {
+ 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::display_output(&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);
+ 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 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::display_output(&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;
+ let reask = workflow::reask_verdict_prompt(&reason);
+ if self.workflow_followup(child_id, reask) {
+ 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);
+ // Treat a reviewer that cannot produce a parseable verdict the
+ // same way as one whose process died: abstain from this round.
+ // Failing the whole pipeline because one agent wrote prose
+ // twice would throw away a complete implementation.
+ self.workflow_event(
+ parent_id,
+ wire::WorkflowEventKind::AgentOutput,
+ format!("{label} abstained"),
+ Some(format!(
+ "No parseable verdict after a retry ({reason}) — excluded from this \
+ round's verdict."
+ )),
+ Some(StageKind::Review),
+ Vec::new(),
+ wire::WorkflowEventTone::Warning,
+ );
+ let Some(mut run) = self.workflow_runs.remove(parent_id) else {
+ return;
+ };
+ run.review_pending.remove(child_id);
+ run.active_children.remove(child_id);
+ run.reasked.remove(child_id);
+ run.set_record_status(child_id, wire::OrchNodeStatus::Failed);
+ self.workflow_set_child_status(child_id, TaskStatus::Idle);
+ if run.review_pending.is_empty() {
+ if run.review_collected.is_empty() {
+ self.workflow_runs.insert(parent_id.to_string(), run);
+ let _ = self
+ .workflow_finalize(
+ parent_id,
+ WorkflowOutcome::Error(
+ "no reviewer produced a usable verdict".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);
+ }
+ }
+ }
+ }
+
+ /// 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);
+ let _ = 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);
+ let _ = 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);
+ let _ = 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 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 active: Vec = run.active_children.keys().cloned().collect();
+ let mut handles = Vec::new();
+ 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 {
+ 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_within(super::acp::STOP_GRACE).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,
+ ) -> Result<(), String> {
+ let Some(mut run) = self.workflow_runs.remove(parent_id) else {
+ return Ok(());
+ };
+ if !run.is_active() {
+ self.workflow_runs.insert(parent_id.to_string(), run);
+ return Ok(());
+ }
+ let stop_result = self.workflow_stop_children(&mut run).await;
+
+ 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);
+ // The state transition succeeded even if a stage process was slow to
+ // die; surfacing teardown trouble as the RPC's error would make a
+ // completed decision look rejected.
+ if let Err(error) = stop_result {
+ self.workflow_timeline(
+ parent_id,
+ format!("Note: a stage agent did not shut down cleanly ({error})."),
+ );
+ }
+ Ok(())
+ }
+
+ /// 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, StageText::default())
+ .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![],
+ },
+ );
+ if self.workflow_followup(&child, message.clone()) {
+ 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 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 = guidance;
+ // Asking for more rounds supersedes a pause requested
+ // while the last review was still running; otherwise the
+ // next stage would park immediately after we just said
+ // "continuing with a fix".
+ run.pause_requested = false;
+ }
+ 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 {
+ // Leaving the row in place would re-fail on every start while
+ // the parent sits with no pipeline state and therefore no
+ // pause/resume/stop controls. Say so, once, and move on.
+ eprintln!("[daemon] dropping unreadable workflow run for task {task_id}");
+ if let Some(store) = &self.store {
+ let _ = store.delete_workflow_run(&task_id);
+ }
+ if let Some(task) = self.tasks.get_mut(&task_id) {
+ task.blocked_reason =
+ Some("workflow state could not be restored after an upgrade".to_string());
+ task.set_status(TaskStatus::Blocked);
+ let updated = task.clone();
+ self.persist(&updated);
+ }
+ 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();
+ // Re-running a review re-increments `round` on spawn, so
+ // give the interrupted round back — otherwise a restart
+ // during round 2 of 2 resumes as "round 3/2" and lands
+ // straight on the limit decision.
+ if stage == StageKind::Review {
+ run.round = run.round.saturating_sub(1);
+ }
+ run.state = RunState::Paused { next: stage };
+ // The working copy may hold half-applied edits from the
+ // killed attempt; the re-run has to know that.
+ run.pending_guidance = Some(
+ "A previous attempt of this stage was interrupted by a daemon restart. \
+ The working copy may already contain its partial changes — inspect the \
+ current diff before assuming you are starting from scratch."
+ .to_string(),
+ );
+ 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 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;
+ 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) {
+ 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);
+ }
+ }
+}
+
+/// A finished stage's text, split into the agent's closing message and the
+/// whole turn. See [`Daemon::collect_stage_text`].
+#[derive(Debug, Default, Clone)]
+struct StageText {
+ closing: String,
+ full: String,
+}
+
/// 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..7a21a48 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)]
@@ -326,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 {
@@ -573,6 +574,722 @@ 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,
+ include_runtime_context: false,
+ config_overrides: std::collections::HashMap::new(),
+ 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);
+ // 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
+ // 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();
+ // The finding reaches the timeline through the round's merged-verdict
+ // entry. The reviewer's own card shows its prose, NOT the raw protocol
+ // JSON it was asked to emit — that is stripped for display.
+ let finding = workflow_events
+ .iter()
+ .position(|update| {
+ matches!(
+ update,
+ wire::SessionUpdate::WorkflowEvent { detail: Some(detail), .. }
+ if detail.contains("bug here")
+ )
+ })
+ .expect("the merged verdict lists the finding");
+ assert!(
+ workflow_events.iter().all(|update| !matches!(
+ update,
+ wire::SessionUpdate::WorkflowEvent { detail: Some(detail), .. }
+ if detail.contains("\"verdict\"")
+ )),
+ "the machine protocol block must not leak into the parent's timeline"
+ );
+ 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"
+ );
+ 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");
+ }
+
+ /// Reviewers must receive the implementer's closing message, not the
+ /// whole turn's tool narration.
+ #[tokio::test]
+ async fn workflow_carries_the_closing_message_not_the_narration() {
+ 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: Closing flow\nreview:\n reviewers:\n - agent: {reviewer}\n"),
+ )
+ .unwrap();
+ let lead = wf_agent(&dir, "impl.state", "noisy-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;
+
+ 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;
+
+ // The reviewer child's prompt is its task prompt.
+ let snapshot = daemon.snapshot().await;
+ let reviewer_prompt = snapshot
+ .tasks
+ .iter()
+ .find(|t| {
+ t.parent_task_id.as_deref() == Some(&parent_id) && t.title.starts_with("review")
+ })
+ .map(|t| t.prompt.clone())
+ .expect("a reviewer stage ran");
+ assert!(
+ reviewer_prompt.contains("CLOSING: implemented the change"),
+ "the reviewer must see the closing message"
+ );
+ assert!(
+ !reviewer_prompt.contains("NARRATION:"),
+ "tool narration must not be passed off as the implementer's summary"
+ );
+
+ // The parent's timeline shows the same closing text as the stage result.
+ let events: Vec<_> = snapshot.session_history[&parent_id]
+ .iter()
+ .filter_map(|update| match update {
+ wire::SessionUpdate::WorkflowEvent { detail, .. } => detail.clone(),
+ _ => None,
+ })
+ .collect();
+ assert!(
+ events.iter().any(|detail| detail.contains("CLOSING:")),
+ "{events:?}"
+ );
+ assert!(
+ events.iter().all(|detail| !detail.contains("NARRATION:")),
+ "{events:?}"
+ );
+ }
+
+ #[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]
+ 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;
+ 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
+ .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;
+
+ 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();
+ 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");
+
+ 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
+ .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);
+ }
+
+ #[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]
+ 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::Idle);
+ 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 762ddb8..4a87a3a 100644
--- a/src/daemon/server.rs
+++ b/src/daemon/server.rs
@@ -418,7 +418,34 @@ 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,
+ include_runtime_context,
+ config_overrides,
+ 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,
@@ -584,7 +611,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 } => {
@@ -831,6 +864,59 @@ 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() }))
+ }
+ 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())
@@ -899,6 +985,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 +1018,56 @@ 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 {
+ 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 +1133,7 @@ fn method_is_mutation(method: &wire::Method) -> bool {
| GitPushInfo { .. }
| OrchestrateList {}
| OrchestrateGetConfig {}
+ | WorkflowList { .. }
| BootstrapFinalize { .. }
| BootstrapReadConfig { .. }
)
@@ -1091,6 +1234,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/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..392bd58
--- /dev/null
+++ b/src/daemon/workflow.rs
@@ -0,0 +1,1614 @@
+//! 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`. 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;
+/// Budget for a salvaged review body used as a stand-in finding.
+const SALVAGED_FINDING_MAX_BYTES: usize = 4 * 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 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,
+ 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)
+ }
+
+ /// Map an agent's severity word onto the four levels. Agents use a much
+ /// wider vocabulary than the protocol asks for, and the default matters:
+ /// an unknown word becomes `Medium`, which forces a repair round, so
+ /// opinion-shaped words must land in `Low` rather than fall through.
+ fn parse(s: &str) -> Severity {
+ match s.trim().to_ascii_lowercase().as_str() {
+ "critical" | "blocker" | "blocking" | "severe" | "fatal" => Severity::Critical,
+ "high" | "major" | "important" => Severity::High,
+ "low" | "minor" | "nit" | "nitpick" | "info" | "informational" | "suggestion"
+ | "style" | "cosmetic" | "trivial" | "optional" | "polish" | "note" => Severity::Low,
+ _ => Severity::Medium,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Finding {
+ pub severity: Severity,
+ pub file: Option,
+ /// Line in the post-change file, when the reviewer pinned one down.
+ #[serde(default)]
+ pub line: Option,
+ /// A short verbatim excerpt of the offending code. More robust than a line
+ /// number — the fixer can search for it after the file has shifted.
+ #[serde(default)]
+ pub snippet: 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.
+ #[serde(default)]
+ pub open_findings: Vec,
+ /// Low-severity findings accumulated for the final summary only.
+ #[serde(default)]
+ pub deferred_findings: Vec,
+ /// child task id → reviewer index, while a review stage is in flight.
+ #[serde(default)]
+ pub review_pending: HashMap,
+ /// (reviewer index, verdict, findings) collected this round.
+ #[serde(default)]
+ pub review_collected: Vec<(usize, Verdict, Vec)>,
+ /// child task id → verdict re-ask count.
+ #[serde(default)]
+ pub reasked: HashMap,
+ /// child task id → stage kind, for routing TurnEnded (single-child stages).
+ #[serde(default)]
+ 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,
+ #[serde(default)]
+ pub history: Vec,
+ /// Attachments from the New Task dialog, delivered to the first stage.
+ #[serde(default)]
+ pub attachments: Vec,
+ /// Whether stage sessions get the project's runtime-context preamble, as
+ /// picked in the New Task dialog.
+ #[serde(default)]
+ pub include_runtime_context: bool,
+ /// Non-model session config picks from the dialog (reasoning effort, mode),
+ /// applied to every stage session.
+ #[serde(default)]
+ pub config_overrides: HashMap,
+}
+
+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,
+ include_runtime_context: bool,
+ config_overrides: HashMap,
+ ) -> 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(),
+ prior_review_children: HashMap::new(),
+ reasked: HashMap::new(),
+ active_children: HashMap::new(),
+ history: Vec::new(),
+ attachments: Vec::new(),
+ include_runtime_context,
+ config_overrides,
+ }
+ .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)
+ }
+
+ /// 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()
+ }
+
+ /// 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 {
+ 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,
+ pause_requested: self.pause_requested,
+ }
+ }
+
+ 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_with_key(text, "need_user_input") {
+ if let Some(q) = value.get("need_user_input").and_then(|v| v.as_str()) {
+ let q = q.trim();
+ // Ignore an agent that echoes the protocol example back at us
+ // instead of asking something.
+ if !q.is_empty() && q != "" {
+ 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_with_key(text, "verdict") else {
+ return Err("no fenced JSON block with a `verdict` field 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("the `verdict` field is not a string".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()),
+ // Agents emit the line as a number or a string, and sometimes
+ // as a range ("42-45") — the first number is the anchor.
+ line: item
+ .get("line")
+ .and_then(|v| {
+ v.as_u64().or_else(|| {
+ v.as_str().and_then(|s| {
+ s.trim()
+ .split(|c: char| !c.is_ascii_digit())
+ .find(|part| !part.is_empty())
+ .and_then(|part| part.parse().ok())
+ })
+ })
+ })
+ .and_then(|n| u32::try_from(n).ok())
+ .filter(|n| *n > 0),
+ snippet: item
+ .get("snippet")
+ .or_else(|| item.get("code"))
+ .and_then(|v| v.as_str())
+ .map(|s| clip_snippet(s.trim()))
+ .filter(|s| !s.is_empty()),
+ description,
+ reviewer: reviewer.to_string(),
+ });
+ }
+ }
+ // A reviewer that asks for changes but leaves `findings` empty (prose-only
+ // review, a differently-named key, entries without a description) must not
+ // read as "nothing to fix" — that path finishes the pipeline as a success
+ // and silently rubber-stamps the change. Salvage its own words instead so
+ // the repair stage still has something concrete to act on.
+ if verdict == Verdict::RequestChanges && findings.is_empty() {
+ let prose = strip_protocol_blocks(text).trim().to_string();
+ let prose = if prose.len() > SALVAGED_FINDING_MAX_BYTES {
+ let end = floor_char_boundary(&prose, SALVAGED_FINDING_MAX_BYTES);
+ format!("{}\n[…truncated…]", &prose[..end])
+ } else {
+ prose
+ };
+ findings.push(Finding {
+ severity: Severity::Medium,
+ file: None,
+ line: None,
+ snippet: None,
+ description: if prose.is_empty() {
+ "Changes were requested without any detail. Re-check the diff against the task \
+ and fix what looks wrong."
+ .to_string()
+ } else {
+ format!(
+ "Changes requested without a structured findings list — the reviewer's own \
+ words follow:\n\n{prose}"
+ )
+ },
+ reviewer: reviewer.to_string(),
+ });
+ }
+
+ Ok((verdict, findings))
+}
+
+/// The last fenced code block in `text` that parses as a JSON object
+/// containing `key`. Requiring the key matters: agents routinely end a reply
+/// with an unrelated fenced block (a config snippet, a quoted diff), and
+/// taking the last JSON object unconditionally would misread that as the
+/// protocol payload. Accepts both ```json and bare ``` fences — agents are
+/// inconsistent.
+fn extract_last_json_with_key(text: &str, key: &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.get(key).is_some() {
+ 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
+}
+
+/// Strip the trailing machine protocol block (the `verdict` / `need_user_input`
+/// JSON fence) so the parent's timeline shows the agent's prose instead of the
+/// wire format it was asked to emit. Falls back to the full text when nothing
+/// matches, and always clips to the summary budget.
+pub fn display_output(text: &str) -> String {
+ let trimmed = strip_protocol_blocks(text).trim().to_string();
+ let trimmed = trimmed.as_str();
+ if trimmed.is_empty() {
+ // The whole reply was the protocol block — show it rather than an
+ // empty card.
+ return clip_summary(text.trim());
+ }
+ clip_summary(trimmed)
+}
+
+/// Drop the trailing machine protocol fences from an agent reply.
+fn strip_protocol_blocks(text: &str) -> &str {
+ let mut visible = text;
+ for key in ["verdict", "need_user_input"] {
+ if let Some(start) = protocol_block_start(visible, key) {
+ visible = &visible[..start];
+ }
+ }
+ visible
+}
+
+/// Byte offset of the fence that opens the last JSON block containing `key`.
+fn protocol_block_start(text: &str, key: &str) -> Option {
+ let mut found = None;
+ let mut cursor = 0;
+ while let Some(open) = text[cursor..].find("```").map(|p| cursor + p) {
+ let after_open = open + 3;
+ let Some(nl) = text[after_open..].find('\n').map(|p| after_open + p + 1) else {
+ break;
+ };
+ let Some(close) = text[nl..].find("```").map(|p| nl + p) else {
+ break;
+ };
+ if serde_json::from_str::(text[nl..close].trim())
+ .ok()
+ .and_then(|value| value.get(key).cloned())
+ .is_some()
+ {
+ found = Some(open);
+ }
+ cursor = close + 3;
+ }
+ found
+}
+
+/// 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 location = match (f.file.as_deref(), f.line) {
+ (Some(path), Some(line)) => format!(" `{path}:{line}`"),
+ (Some(path), None) => format!(" `{path}`"),
+ (None, _) => String::new(),
+ };
+ let mut entry = format!(
+ "{}. [{}]{} — {} ({})",
+ i + 1,
+ f.severity.label(),
+ location,
+ f.description,
+ f.reviewer
+ );
+ if let Some(snippet) = f.snippet.as_deref() {
+ // A verbatim excerpt survives line drift: the fixer can search
+ // for it even after the file has moved underneath the number.
+ entry.push_str(&format!("\n at: `{snippet}`"));
+ }
+ entry
+ })
+ .collect::>()
+ .join("\n")
+}
+
+/// Keep a code anchor short: a reviewer that pastes a whole function would
+/// otherwise dominate the repair prompt.
+fn clip_snippet(snippet: &str) -> String {
+ const MAX: usize = 200;
+ let one_line = snippet.replace('\n', " ").trim().to_string();
+ if one_line.len() <= MAX {
+ return one_line;
+ }
+ let end = floor_char_boundary(&one_line, MAX);
+ format!("{}…", &one_line[..end])
+}
+
+/// True when `text` carries either machine protocol payload. Used to decide
+/// whether an agent's closing message is the authoritative output or whether
+/// the payload only appears earlier in the turn.
+pub fn has_protocol_payload(text: &str) -> bool {
+ extract_last_json_with_key(text, "verdict").is_some()
+ || extract_last_json_with_key(text, "need_user_input").is_some()
+}
+
+/// 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,
+ /// 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,
+}
+
+/// Appended to every plan/implement/fix prompt — the question protocol the
+/// engine's `parse_stage_signal` understands.
+const QUESTION_PROTOCOL: &str = "This stage runs unattended, so stop for the user only when you \
+genuinely cannot proceed — a decision only they can make, or missing access. Otherwise pick the \
+most reasonable option, proceed, and record the assumption in your final message. When you do \
+need them, 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 = "Scope: judge correctness, regressions, and whether the task's \
+stated requirements are met. Style, naming, and improvements nobody asked for are `low` at most. \
+Report real problems only — do not invent findings to seem thorough. You may run read-only \
+verification (build, tests, linters) to check your reasoning, but do not edit files.\n\n\
+You MUST end your reply with exactly one fenced code block, and nothing after it, of this \
+shape:\n\
+```json\n{\"verdict\": \"request_changes\", \"findings\": [{\"severity\": \"high\", \"file\": \"src/example.rs\", \"line\": 42, \"snippet\": \"if attempts > max {\", \"description\": \"what is wrong and why\"}]}\n```\n\
+Anchor every finding you can: `line` is the line in the file AFTER the change \
+(each diff hunk header `@@ -old +new @@` gives you the new-file line its body \
+starts at), and `snippet` is one short verbatim line of the offending code. \
+Both are optional but they let the repair stage go straight to the right place \
+instead of searching.\n\
+When you approve, send `{\"verdict\": \"approve\", \"findings\": []}` — use `approve` only when no \
+critical, high, or medium problem remains. `findings` MUST be a JSON array (empty when there are \
+none) and every entry MUST carry a `description`: anything you only write in prose is invisible \
+to the pipeline. `severity` is critical, high, medium, or low, and `file` may be null. Note that \
+`low` findings are recorded for the human but are NEVER sent to the repair stage — use `medium` \
+or higher for anything you actually want fixed.";
+
+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
+ }
+ };
+ 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)),
+ 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![],
+ false,
+ HashMap::new(),
+ )
+ }
+
+ #[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"));
+ // A JSON block without a `verdict` key is not the protocol payload.
+ assert!(
+ parse_review_verdict("```json\n{\"findings\": []}\n```", "r")
+ .unwrap_err()
+ .contains("no fenced JSON")
+ );
+ 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 verdict_parsing_reads_finding_anchors() {
+ let text = r#"```json
+{"verdict": "request_changes", "findings": [
+ {"severity": "high", "file": "src/a.rs", "line": 42, "snippet": "if attempts > max {", "description": "off-by-one"},
+ {"severity": "high", "file": "src/b.rs", "line": "17-20", "code": "let x = 1;", "description": "string range"},
+ {"severity": "low", "line": 0, "description": "no anchor"}
+]}
+```"#;
+ let (_, findings) = parse_review_verdict(text, "r").unwrap();
+ assert_eq!(findings[0].line, Some(42));
+ assert_eq!(findings[0].snippet.as_deref(), Some("if attempts > max {"));
+ // A range and a `code` alias both resolve.
+ assert_eq!(findings[1].line, Some(17));
+ assert_eq!(findings[1].snippet.as_deref(), Some("let x = 1;"));
+ // Line 0 is not a location.
+ assert_eq!(findings[2].line, None);
+ assert_eq!(findings[2].snippet, None);
+
+ // Anchors reach the repair prompt.
+ let rendered = format_findings(&findings);
+ assert!(rendered.contains("`src/a.rs:42`"), "{rendered}");
+ assert!(rendered.contains("at: `if attempts > max {`"), "{rendered}");
+ assert!(rendered.contains("`src/b.rs:17`"), "{rendered}");
+
+ // An oversized excerpt is clipped to one line.
+ let long = format!(
+ "```json\n{{\"verdict\": \"approve\", \"findings\": [{{\"description\": \"d\", \"snippet\": \"{}\"}}]}}\n```",
+ "x".repeat(400)
+ );
+ let (_, findings) = parse_review_verdict(&long, "r").unwrap();
+ let snippet = findings[0].snippet.as_deref().unwrap();
+ assert!(snippet.len() <= 204, "{}", snippet.len());
+ assert!(snippet.ends_with('…'));
+ }
+
+ #[test]
+ fn protocol_payload_detection() {
+ assert!(has_protocol_payload(
+ "```json\n{\"verdict\": \"approve\"}\n```"
+ ));
+ assert!(has_protocol_payload(
+ "```json\n{\"need_user_input\": \"which db?\"}\n```"
+ ));
+ // An unrelated JSON block is not a protocol payload — this is what
+ // keeps a quoted config file from hijacking a stage's result.
+ assert!(!has_protocol_payload("```json\n{\"name\": \"pkg\"}\n```"));
+ assert!(!has_protocol_payload("just prose"));
+ }
+
+ #[test]
+ fn merge_reviews_requires_unanimous_approve() {
+ let f = |desc: &str| Finding {
+ severity: Severity::High,
+ file: None,
+ line: None,
+ snippet: 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()),
+ prior_findings: None,
+ 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 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 {
+ 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!(!info.pause_requested);
+ assert_eq!(info.max_rounds, 2);
+
+ // A requested pause is visible before it takes effect, so the UI can
+ // show progress rather than an idle Pause button.
+ run.pause_requested = true;
+ assert!(run.wire_info().pause_requested);
+ run.pause_requested = false;
+
+ 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,
+ line: None,
+ snippet: 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()),
+ line: Some(7),
+ snippet: Some("let x = 1;".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/main.rs b/src/main.rs
index 97cad53..3612a55 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 {
@@ -102,8 +103,10 @@ async fn main() -> Result<()> {
let config_file = config::find_config_file(std::path::Path::new(&entry.path));
if !config_file.exists() {
match config::generate_workspace_yaml(std::path::Path::new(&entry.path)) {
- Ok(_) => println!("Created .warpforge.yaml — edit it to configure services"),
- Err(e) => println!("Note: could not create .warpforge.yaml: {}", e),
+ Ok(_) => println!(
+ "Created .warpforge/workspace.yaml — edit it to configure services"
+ ),
+ Err(e) => println!("Note: could not create .warpforge/workspace.yaml: {}", e),
}
}
}
@@ -154,7 +157,7 @@ async fn main() -> Result<()> {
/// Interactive CLI bootstrap: register the project, ask a few runtime
/// questions, run a config-gen task on the daemon, then review + write the
-/// proposed `.warpforge.yaml`. The daemon owns the repo scan, prompt building,
+/// proposed workspace config. The daemon owns the repo scan, prompt building,
/// and validation (see `bootstrap.*` RPCs); this only drives the flow.
async fn run_bootstrap(path: &str) -> Result<()> {
use std::io::{self, Write};
@@ -250,7 +253,7 @@ async fn run_bootstrap(path: &str) -> Result<()> {
.unwrap_or_default();
let yaml = bootstrap::extract_yaml_from_response(&response);
- println!("\n── Proposed .warpforge.yaml ──\n{yaml}\n──────────────────────────────");
+ println!("\n── Proposed workspace config ──\n{yaml}\n───────────────────────────────");
match bootstrap::validate_config_yaml(&yaml) {
Ok((_, issues)) => {
for issue in &issues {
@@ -269,6 +272,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..5826abd
--- /dev/null
+++ b/src/workflow_config.rs
@@ -0,0 +1,1026 @@
+//! 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, Serialize};
+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;
+/// Default review rounds. A fix runs *between* rounds, so N rounds buy N-1
+/// repair attempts: 3 keeps a second attempt available when the first fix
+/// misses, which is the common case.
+pub const DEFAULT_MAX_ROUNDS: u32 = 3;
+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, Serialize, Deserialize)]
+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, Serialize, Deserialize)]
+pub struct StageConfig {
+ pub agent: Option,
+ pub model: Option,
+ pub prompt: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+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,
+ // These collections carry `serde(default)` because a `WorkflowSpec` is
+ // persisted inside a running pipeline's snapshot: a field that is required
+ // on load turns every in-flight run unreadable after an upgrade.
+ #[serde(default)]
+ pub context: Vec,
+ /// Always 1..=MAX_REVIEWERS entries; defaults to one all-`None` reviewer.
+ #[serde(default)]
+ pub reviewers: Vec,
+ /// True when the YAML set `context` explicitly. Only drives the warning
+ /// that the key is inert alongside custom reviewer prompts.
+ #[serde(default, skip_serializing)]
+ pub context_was_set: bool,
+}
+
+/// 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")]
+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, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+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, Serialize, Deserialize)]
+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()))
+}
+
+/// Like [`nullable`], but `false` means "this stage is off". Deleting the key
+/// is the canonical way to disable planning; `plan: false` is the obvious
+/// guess, so accept it instead of failing with a type error.
+fn nullable_or_false<'de, D, T>(deserializer: D) -> Result, D::Error>
+where
+ D: serde::Deserializer<'de>,
+ T: Deserialize<'de> + Default,
+{
+ #[derive(Deserialize)]
+ #[serde(untagged)]
+ enum Toggle {
+ Off(bool),
+ On(T),
+ }
+ match Option::>::deserialize(deserializer)? {
+ None => Ok(Some(T::default())),
+ Some(Toggle::Off(false)) => Ok(None),
+ Some(Toggle::Off(true)) => Ok(Some(T::default())),
+ Some(Toggle::On(value)) => Ok(Some(value)),
+ }
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct RawWorkflow {
+ version: Option,
+ name: Option,
+ description: Option,
+ #[serde(default, deserialize_with = "nullable_or_false")]
+ 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,
+ reask: 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);
+ // Deserialize from the source text, not the `Value` above: only `from_str`
+ // reports the offending key path plus line and column, and that message is
+ // what the picker shows as the reason a workflow is rejected.
+ let raw: RawWorkflow = match serde_yaml::from_str(yaml) {
+ 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", "reask", "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)?;
+
+ // A placeholder this workflow can never populate renders as an empty
+ // section, so scope the allow-lists: `{{plan}}` needs a plan stage and
+ // `{{focus}}` needs that reviewer to define one.
+ let has_plan = plan.is_some();
+ let allow = |vars: &[&'static str], _focus: bool| -> Vec<&'static str> {
+ vars.iter()
+ .copied()
+ .filter(|name| match *name {
+ "plan" => has_plan,
+ _ => true,
+ })
+ .collect()
+ };
+ validate_prompt(
+ plan.as_ref().and_then(|s| s.prompt.as_deref()),
+ "plan",
+ &allow(VARS_PLAN, false),
+ )?;
+ validate_prompt(
+ implement.prompt.as_deref(),
+ "implement",
+ &allow(VARS_IMPLEMENT, false),
+ )?;
+ validate_prompt(fix.prompt.as_deref(), "fix", &allow(VARS_FIX, false))?;
+ for (i, reviewer) in review.reviewers.iter().enumerate() {
+ validate_prompt(
+ reviewer.prompt.as_deref(),
+ &format!("review.reviewers[{i}]"),
+ &allow(VARS_REVIEW, reviewer.focus.is_some()),
+ )?;
+ }
+ // `context` and `focus` only shape the BUILT-IN reviewer prompt: a custom
+ // prompt renders its own context. Silently ignoring them is the most
+ // confusing possible outcome for the first edit a user makes.
+ if review.reviewers.iter().all(|r| r.prompt.is_some()) {
+ if review.context_was_set {
+ warnings.push(
+ "review.context is ignored because every reviewer defines its own `prompt` — a \
+ custom prompt decides what context it includes"
+ .to_string(),
+ );
+ }
+ for (i, reviewer) in review.reviewers.iter().enumerate() {
+ let uses_focus = reviewer
+ .prompt
+ .as_deref()
+ .is_some_and(|p| extract_placeholders(p).iter().any(|v| v == "focus"));
+ if reviewer.focus.is_some() && !uses_focus {
+ warnings.push(format!(
+ "review.reviewers[{i}].focus is ignored because that reviewer's `prompt` \
+ never uses {{{{focus}}}}"
+ ));
+ }
+ }
+ }
+
+ 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 context_was_set = raw.context.is_some();
+ 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 reask = match raw.reask.as_deref() {
+ None => 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,
+ 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,
+ reask,
+ context,
+ reviewers,
+ context_was_set,
+ })
+}
+
+// ─── 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: &[&'static 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.reask, ReaskMode::SameSession);
+ 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
+ reask: fresh
+ 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.reask, ReaskMode::Fresh);
+ 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 reask: never\n").contains("`same_session` or `fresh`")
+ );
+ 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());
+ // {{plan}} is rejected when the workflow has no planning stage — it
+ // would render an empty section instead of a plan.
+ let err = parse_err("name: X\nimplement:\n prompt: \"Plan: {{plan}}\"\n");
+ assert!(err.contains("{{plan}}"), "{err}");
+ assert!(parse_workflow(
+ "test",
+ "name: X\nplan: {}\nimplement:\n prompt: \"Plan: {{plan}}\"\n"
+ )
+ .0
+ .is_ok());
+ }
+
+ #[test]
+ fn inert_reviewer_knobs_warn() {
+ // `context` and `focus` only shape the built-in reviewer prompt, so
+ // setting them next to a custom prompt must not pass silently.
+ let (spec, warnings) = parse_workflow(
+ "test",
+ "name: X\nreview:\n context: [prompt]\n reviewers:\n - focus: security\n prompt: \"check it\"\n",
+ );
+ assert!(spec.is_ok());
+ assert!(
+ warnings
+ .iter()
+ .any(|w| w.contains("review.context is ignored")),
+ "{warnings:?}"
+ );
+ assert!(
+ warnings
+ .iter()
+ .any(|w| w.contains("focus is ignored") && w.contains("{{focus}}")),
+ "{warnings:?}"
+ );
+ // A custom prompt that actually uses {{focus}} draws no warning.
+ let (_, warnings) = parse_workflow(
+ "test",
+ "name: X\nreview:\n reviewers:\n - focus: security\n prompt: \"{{focus}}\"\n",
+ );
+ assert!(warnings.is_empty(), "{warnings:?}");
+ }
+
+ #[test]
+ fn plan_false_disables_the_stage() {
+ assert!(parse_ok("name: X\nplan: false\n").0.plan.is_none());
+ assert!(parse_ok("name: X\nplan: true\n").0.plan.is_some());
+ }
+
+ #[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..6074709
--- /dev/null
+++ b/src/workflows/plan-review-loop.yaml
@@ -0,0 +1,87 @@
+# Built-in workflow: plan → implement → review ⇄ fix.
+#
+# 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 + implement + review loop
+description: Plan first, implement the plan, then loop reviews and fixes until approved.
+
+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: 3 # review rounds; a fix runs between them, so 3 = two fix attempts
+ on_limit: ask # ask | finish — behaviour when the limit is hit
+ # reask: same_session # same_session | fresh — repeat rounds follow up in the
+ # same reviewer session (it verifies its own findings); `fresh` spawns new
+ # reviewers each round, with last round's findings included for verification.
+ reviewers:
+ - # agent: claude
+ # model: claude-opus-5
+ # focus: correctness and edge cases # only used if you delete `prompt` below
+ 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
new file mode 100644
index 0000000..c7a8a92
--- /dev/null
+++ b/src/workflows/review-loop.yaml
@@ -0,0 +1,69 @@
+# 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: 3 # review rounds; a fix runs between them, so 3 = two fix attempts
+ on_limit: ask # ask | finish — behaviour when the limit is hit
+ # reask: same_session # same_session | fresh — repeat rounds follow up in the
+ # same reviewer session (it verifies its own findings); `fresh` spawns new
+ # reviewers each round, with last round's findings included for verification.
+ 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 # only used if you delete `prompt` below
+ 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/tests/fixtures/mock-acp-workflow.mjs b/tests/fixtures/mock-acp-workflow.mjs
new file mode 100644
index 0000000..a5f7645
--- /dev/null
+++ b/tests/fixtures/mock-acp-workflow.mjs
@@ -0,0 +1,114 @@
+// 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 "noisy-impl":
+ // Narration, then work, then the real closing message — only the last
+ // part is the summary the pipeline should carry forward.
+ text("NARRATION: let me look at the files first.");
+ update({
+ sessionUpdate: "tool_call",
+ toolCallId: "t1",
+ title: "read src/lib.rs",
+ status: "completed",
+ });
+ text("CLOSING: implemented the change and ran the tests.");
+ 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 "reject-die":
+ // Reject, then die shortly after: simulates a reviewer whose session
+ // is gone by the time the next round wants to follow up in it.
+ text(
+ 'Found problems.\n```json\n{"verdict": "request_changes", "findings": [{"severity": "high", "file": "a.rs", "description": "bug here"}]}\n```'
+ );
+ endTurn(msg.id);
+ setTimeout(() => 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);
+ break;
+ default:
+ text(`unknown behavior: ${behavior}`);
+ endTurn(msg.id);
+ }
+ }
+}