mirror of
https://github.com/Hmbown/DeepSeek-TUI.git
synced 2026-09-03 06:50:13 +08:00
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:
@@ -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.
|
||||
|
||||
218
crates/workflow/src/named_fleet.rs
Normal file
218
crates/workflow/src/named_fleet.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
42
fleets/v0868-stopship.toml
Normal file
42
fleets/v0868-stopship.toml
Normal 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"
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user