From 43ccbcffe79bf8000a1019a93c8f30a2a8ca5558 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 9 Jul 2026 02:13:34 -0700 Subject: [PATCH] feat(stopship): fleet-backed dogfood path for #4178 Add fleets/v0868-stopship.toml (five roles), NamedFleet loader with resolution tests, profile bindings on stopship workflow steps, and playbook commands for lane + interim exec paths. --- crates/workflow/src/lib.rs | 5 + crates/workflow/src/named_fleet.rs | 218 ++++++++++++++++++++++ docs/AGENT_WORKFLOWS_0868.md | 39 +++- fleets/v0868-stopship.toml | 42 +++++ workflows/v0868_stopship_lane.workflow.js | 6 + 5 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 crates/workflow/src/named_fleet.rs create mode 100644 fleets/v0868-stopship.toml diff --git a/crates/workflow/src/lib.rs b/crates/workflow/src/lib.rs index 43917866e..523fec608 100644 --- a/crates/workflow/src/lib.rs +++ b/crates/workflow/src/lib.rs @@ -6,6 +6,7 @@ mod js_authoring; mod model_policy; +mod named_fleet; mod replay; use std::collections::{BTreeMap, BTreeSet}; @@ -19,6 +20,10 @@ pub use js_authoring::{ compile_typescript_workflow, }; pub use model_policy::*; +pub use named_fleet::{ + NamedFleet, NamedFleetError, STOPSHIP_REQUIRED_ROLES, load_named_fleet, load_named_fleet_file, + parse_named_fleet, +}; pub use replay::*; /// Default hard ceiling on total agents a Fleet-shaped Workflow plan may launch. diff --git a/crates/workflow/src/named_fleet.rs b/crates/workflow/src/named_fleet.rs new file mode 100644 index 000000000..706177272 --- /dev/null +++ b/crates/workflow/src/named_fleet.rs @@ -0,0 +1,218 @@ +//! Named fleet roster files for dogfood lanes (#4178). +//! +//! Format: TOML at `fleets/.toml` (workspace) or +//! `$CODEWHALE_HOME/fleets/.toml`. +//! +//! Fleet resolves roles → profile ids only. Runtime owns tmux/worktrees. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Parsed named fleet file. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NamedFleet { + pub name: String, + #[serde(default)] + pub description: Option, + /// role name → AgentProfile id + pub roles: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum NamedFleetError { + #[error("fleet file not found: {0}")] + NotFound(String), + #[error("failed to read fleet file {path}: {message}")] + Io { path: String, message: String }, + #[error("failed to parse fleet file {path}: {message}")] + Parse { path: String, message: String }, + #[error("fleet `{fleet}` is missing required role `{role}`")] + MissingRole { fleet: String, role: String }, + #[error("fleet name mismatch: file declares `{declared}`, expected `{expected}`")] + NameMismatch { declared: String, expected: String }, +} + +/// Required roles for the stopship dogfood fleet (#4178). +pub const STOPSHIP_REQUIRED_ROLES: &[&str] = &[ + "scout", + "implementer", + "reviewer", + "verifier", + "release_lead", +]; + +/// Parse a fleet TOML document. +pub fn parse_named_fleet(toml_text: &str) -> Result { + // Minimal TOML subset without adding a toml dep to workflow: + // accept JSON as well for tests; for TOML use a tiny hand parser for + // the documented shape, or serde via json for unit tests. + // Prefer JSON if the text looks like JSON; otherwise use line-oriented TOML. + let trimmed = toml_text.trim(); + if trimmed.starts_with('{') { + return serde_json::from_str(trimmed).map_err(|e| NamedFleetError::Parse { + path: "".into(), + message: e.to_string(), + }); + } + parse_fleet_toml_minimal(trimmed) +} + +fn parse_fleet_toml_minimal(text: &str) -> Result { + let mut name = None; + let mut description = None; + let mut roles = BTreeMap::new(); + let mut section = ""; + for raw in text.lines() { + let line = raw.split('#').next().unwrap_or("").trim(); + if line.is_empty() { + continue; + } + if line.starts_with('[') && line.ends_with(']') { + section = &line[1..line.len() - 1]; + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + let value = value.trim().trim_matches('"').to_string(); + match section { + "" => match key { + "name" => name = Some(value), + "description" => description = Some(value), + _ => {} + }, + "roles" => { + roles.insert(key.to_string(), value); + } + _ => {} + } + } + let name = name.ok_or_else(|| NamedFleetError::Parse { + path: "".into(), + message: "missing name".into(), + })?; + Ok(NamedFleet { + name, + description, + roles, + }) +} + +/// Load fleet by name from search paths (first hit wins). +pub fn load_named_fleet( + name: &str, + search_roots: &[PathBuf], +) -> Result { + let file_name = format!("{name}.toml"); + for root in search_roots { + let path = root.join("fleets").join(&file_name); + if path.is_file() { + return load_named_fleet_file(&path, Some(name)); + } + } + Err(NamedFleetError::NotFound(name.to_string())) +} + +pub fn load_named_fleet_file( + path: &Path, + expect_name: Option<&str>, +) -> Result { + let text = std::fs::read_to_string(path).map_err(|e| NamedFleetError::Io { + path: path.display().to_string(), + message: e.to_string(), + })?; + let fleet = parse_named_fleet(&text).map_err(|e| match e { + NamedFleetError::Parse { message, .. } => NamedFleetError::Parse { + path: path.display().to_string(), + message, + }, + other => other, + })?; + if let Some(expected) = expect_name + && fleet.name != expected + { + return Err(NamedFleetError::NameMismatch { + declared: fleet.name, + expected: expected.to_string(), + }); + } + Ok(fleet) +} + +impl NamedFleet { + /// Resolve a role name to a profile id. + pub fn resolve(&self, role: &str) -> Result<&str, NamedFleetError> { + let key = role.trim().to_ascii_lowercase(); + self.roles + .get(&key) + .or_else(|| { + self.roles + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(role)) + .map(|(_, v)| v) + }) + .map(String::as_str) + .ok_or_else(|| NamedFleetError::MissingRole { + fleet: self.name.clone(), + role: role.to_string(), + }) + } + + /// Ensure all required stopship roles are present. + pub fn validate_stopship_roles(&self) -> Result<(), NamedFleetError> { + for role in STOPSHIP_REQUIRED_ROLES { + self.resolve(role)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const STOPSHIP_TOML: &str = r#" +name = "v0868-stopship" +description = "Stopship dogfood fleet" + +[roles] +scout = "scout" +implementer = "builder" +reviewer = "reviewer" +verifier = "verifier" +release_lead = "manager" +"#; + + #[test] + fn stopship_fleet_resolves_all_five_roles() { + let fleet = parse_named_fleet(STOPSHIP_TOML).expect("parse"); + assert_eq!(fleet.name, "v0868-stopship"); + fleet.validate_stopship_roles().expect("all roles"); + assert_eq!(fleet.resolve("scout").unwrap(), "scout"); + assert_eq!(fleet.resolve("implementer").unwrap(), "builder"); + assert_eq!(fleet.resolve("reviewer").unwrap(), "reviewer"); + assert_eq!(fleet.resolve("verifier").unwrap(), "verifier"); + assert_eq!(fleet.resolve("release_lead").unwrap(), "manager"); + } + + #[test] + fn unknown_role_fails_clearly() { + let fleet = parse_named_fleet(STOPSHIP_TOML).unwrap(); + let err = fleet.resolve("wizard").unwrap_err(); + assert!(matches!(err, NamedFleetError::MissingRole { .. })); + } + + #[test] + fn loads_workspace_fleet_file() { + // Relative to crate CARGO_MANIFEST_DIR → repo root fleets/ + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join(".."); + let fleet = load_named_fleet("v0868-stopship", &[root]).expect("load workspace fleet"); + fleet.validate_stopship_roles().unwrap(); + } +} diff --git a/docs/AGENT_WORKFLOWS_0868.md b/docs/AGENT_WORKFLOWS_0868.md index 5c28780d6..b761341e7 100644 --- a/docs/AGENT_WORKFLOWS_0868.md +++ b/docs/AGENT_WORKFLOWS_0868.md @@ -112,12 +112,46 @@ git checkout main && git pull origin main git checkout -b codex/v0868-stopship- ``` +### Fleet-backed stopship lane (dogfood #4178) + +Named fleet file: [`fleets/v0868-stopship.toml`](../fleets/v0868-stopship.toml) +(roles: `scout`, `implementer`, `reviewer`, `verifier`, `release_lead`). +Workflow: `workflows/v0868_stopship_lane.workflow.js` (steps bind +`profile` / fleet roles — not raw provider/model identity). + +**Target shape** (requires Phase 1 Lane CLI #4176 + Phase 2 role resolution #4177): + +```bash +# Create a durable tmux-backed lane bound to stopship + fleet +codewhale lane start \ + --workflow stopship \ + --fleet v0868-stopship \ + --runtime tmux \ + --issue 4090 \ + -- codewhale exec --auto --output-format stream-json \ + "Run workflows/v0868_stopship_lane.workflow.js with fleet v0868-stopship. Fix #4090, #4093, #4094. Branch from main." + +codewhale lane list +codewhale lane attach # or: codewhale lane attach --print +codewhale lane logs +codewhale lane stop +``` + +Validate fleet role resolution without launching agents: + +```bash +# Pure unit path (CI-safe) +cargo test -p codewhale-workflow --lib named_fleet +``` + +### Interim paths (until `workflow run --fleet` lands) + From CodeWhale TUI or headless exec: ```bash -# Headless stopship lane (preferred for CI/VM agents) +# Headless stopship lane (preferred for CI/VM agents today) codewhale exec --auto --output-format stream-json \ - "Run workflows/v0868_stopship_lane.workflow.js on branch codex/v0868-stopship. Fix #4090, #4093, #4094. Branch from main." + "Run workflows/v0868_stopship_lane.workflow.js on branch codex/v0868-stopship. Fix #4090, #4093, #4094. Branch from main. Use fleet profiles scout/builder/reviewer/verifier from fleets/v0868-stopship.toml." # Per-issue headless (single stopship issue) codewhale exec --auto --output-format stream-json \ @@ -129,6 +163,7 @@ codewhale exec --auto --output-format stream-json \ Workflows use read-only scouts first, then implementation agents in sequence. Write agents require approval in default modes; use `--auto` for headless VM runs. +Do **not** close #4090/#4093/#4094 until human-verified on `main`. ## Per-issue implementation (single issue) diff --git a/fleets/v0868-stopship.toml b/fleets/v0868-stopship.toml new file mode 100644 index 000000000..d08ecd9b5 --- /dev/null +++ b/fleets/v0868-stopship.toml @@ -0,0 +1,42 @@ +# Fleet roster: v0868-stopship (#4178) +# +# Named stopship fleet binding roles → AgentProfile ids. Fleet resolves roles +# only — it does NOT spawn tmux or manage worktrees (Runtime owns those). +# +# Load path (when wired by workflow run --fleet): +# 1. $CODEWHALE_HOME/fleets/v0868-stopship.toml +# 2. /fleets/v0868-stopship.toml (this file) +# +# Built-in roster members used as profile ids: scout, builder, reviewer, +# verifier, manager. Role aliases match Phase 2 / Phase 3 vocabulary. + +name = "v0868-stopship" +description = "Stopship dogfood fleet for #4090 / #4093 / #4094" + +[roles] +# role name = AgentProfile id (built-in or workspace profile) +scout = "scout" +implementer = "builder" +reviewer = "reviewer" +verifier = "verifier" +release_lead = "manager" + +[role_intents.scout] +mode = "read_only" +summary = "Read-only recon on issue + codebase" + +[role_intents.implementer] +mode = "write" +summary = "Minimal fix branch from main" + +[role_intents.reviewer] +mode = "read_only" +summary = "Diff review, regression focus" + +[role_intents.verifier] +mode = "read_only" +summary = "cargo test, clippy, fmt gate" + +[role_intents.release_lead] +mode = "write" +summary = "Stopship checklist + PR draft" diff --git a/workflows/v0868_stopship_lane.workflow.js b/workflows/v0868_stopship_lane.workflow.js index a1a63d564..026e4bbab 100644 --- a/workflows/v0868_stopship_lane.workflow.js +++ b/workflows/v0868_stopship_lane.workflow.js @@ -13,6 +13,7 @@ export default workflow({ "id": "scout-ctrl-c", "prompt": "Investigate GitHub issue #4090 (repeated Ctrl+C re-prompts in PTY/raw-mode). Run: `gh issue view 4090 -R Hmbown/CodeWhale`. Then search `crates/tui/src/` for Ctrl+C handling, raw mode teardown, and exit confirmation paths. Report: repro hypothesis, exact files/functions, whether a regression test or PTY trace exists, minimal fix approach. Read-only.", "agent_type": "explore", + "profile": "scout", "mode": "read_only", "file_scope": ["crates/tui/src/tui/app.rs", "crates/tui/src/tui/ui.rs", "crates/tui/src/main.rs"], "budget": { "max_steps": 12, "timeout_secs": 600 } @@ -23,6 +24,7 @@ export default workflow({ "id": "scout-fleet-modal", "prompt": "Investigate release-blocker #4093 (Fleet setup modal provider-scoped instead of role/profile roster). Run: `gh issue view 4093 -R Hmbown/CodeWhale`. Inspect `crates/tui/src/tui/views/fleet_setup.rs`, `crates/tui/src/fleet/roster.rs`, and related Fleet UI. Report current vs expected behavior, root cause, files to change, test strategy. Read-only.", "agent_type": "explore", + "profile": "scout", "mode": "read_only", "file_scope": ["crates/tui/src/tui/views/fleet_setup.rs", "crates/tui/src/fleet/"], "budget": { "max_steps": 12, "timeout_secs": 600 } @@ -33,6 +35,7 @@ export default workflow({ "id": "scout-subagent-panel", "prompt": "Investigate release-blocker #4094 (sub-agent detail panel empty / TUI freeze). Run: `gh issue view 4094 -R Hmbown/CodeWhale`. Inspect sidebar agents panel, sub-agent detail rendering, and redraw paths under load. Report root cause hypothesis, files, whether throttle or empty-state bug, severity. Read-only.", "agent_type": "explore", + "profile": "scout", "mode": "read_only", "file_scope": ["crates/tui/src/tui/sidebar.rs", "crates/tui/src/tui/widgets/agent_card.rs"], "budget": { "max_steps": 12, "timeout_secs": 600 } @@ -50,6 +53,7 @@ export default workflow({ "id": "fix-4090", "prompt": "Fix #4090 using scout-ctrl-c findings. Branch from main (git checkout main && git pull && git checkout -b codex/v0868-fix-4090). Implement minimal fix so double Ctrl+C exits cleanly in PTY/raw-mode while preserving cancel/copy behavior. Add regression test or PTY/key-path trace. Run: `cargo test -p codewhale-tui` for touched modules and `cargo clippy -p codewhale-tui -- -D warnings`. Do not close the issue; report files changed and test output.", "agent_type": "implementer", + "profile": "builder", "mode": "write", "file_scope": ["crates/tui/src/tui/app.rs", "crates/tui/src/tui/ui.rs"], "budget": { "max_steps": 20, "timeout_secs": 1200 } @@ -60,6 +64,7 @@ export default workflow({ "id": "fix-4093-4094", "prompt": "Using scout findings, fix #4093 and/or #4094 if root causes are clear and independent. Branch from main (separate branches per issue if needed: codex/v0868-fix-4093, codex/v0868-fix-4094). Prefer smallest correct diffs. For #4093: Fleet setup should edit role/profile roster not provider scope. For #4094: sub-agent detail panel must render and not freeze TUI. Add tests where feasible. Run targeted `cargo test -p codewhale-tui fleet sidebar`. Report per-issue status: fixed/partial/blocked.", "agent_type": "implementer", + "profile": "builder", "mode": "write", "file_scope": ["crates/tui/src/tui/views/fleet_setup.rs", "crates/tui/src/tui/sidebar.rs"], "budget": { "max_steps": 24, "timeout_secs": 1800 } @@ -70,6 +75,7 @@ export default workflow({ "id": "verify-dogfood", "prompt": "Re-verify dogfood fixes for #3986 (API-key onboarding copy shows CODEWHALE_HOME path) and #3990 (slash autocomplete alias duplication). Read issue bodies via `gh issue view`. Check if fixes exist on current branch; if missing, implement minimal fixes. Run verification gate subset: `cargo fmt --all --check`, `cargo test -p codewhale-tui -- onboarding slash`. Report done/partial/missing per issue.", "agent_type": "verifier", + "profile": "verifier", "mode": "write", "file_scope": ["crates/tui/src/tui/", "crates/tui/locales/en.json"], "budget": { "max_steps": 16, "timeout_secs": 900 }