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.
This commit is contained in:
Hunter B
2026-07-09 02:13:34 -07:00
parent 931a1df27b
commit 43ccbcffe7
5 changed files with 308 additions and 2 deletions

View File

@@ -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.

View File

@@ -0,0 +1,218 @@
//! Named fleet roster files for dogfood lanes (#4178).
//!
//! Format: TOML at `fleets/<name>.toml` (workspace) or
//! `$CODEWHALE_HOME/fleets/<name>.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<String>,
/// role name → AgentProfile id
pub roles: BTreeMap<String, String>,
}
#[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<NamedFleet, NamedFleetError> {
// 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: "<memory>".into(),
message: e.to_string(),
});
}
parse_fleet_toml_minimal(trimmed)
}
fn parse_fleet_toml_minimal(text: &str) -> Result<NamedFleet, NamedFleetError> {
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: "<memory>".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<NamedFleet, NamedFleetError> {
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<NamedFleet, NamedFleetError> {
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();
}
}

View File

@@ -112,12 +112,46 @@ git checkout main && git pull origin main
git checkout -b codex/v0868-stopship-<issue>
```
### 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 <lane-id> # or: codewhale lane attach <lane-id> --print
codewhale lane logs <lane-id>
codewhale lane stop <lane-id>
```
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)

View File

@@ -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. <workspace>/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"

View File

@@ -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 }