fix(fleet): search <workspace>/.codewhale/fleets as the workspace origin

The Fleet store saves workspace-scoped Fleets to
<workspace>/.codewhale/fleets, but the Workflow loader's `workspace`
origin was the workspace root, so it read <workspace>/fleets and never
saw them. `workspace` now means <workspace>/.codewhale; the root stays a
second origin, `workspace_root`, so checked-in fleets/<name>.toml
rosters keep loading. An exact Fleet present in both is ambiguous and
can be qualified by either origin.

Updates the workflow tool's `fleet` description and FLEET.md (saved
roster locations and origin qualification).

Checks: in the shared tree, cargo test -p codewhale-tui --lib --
tool_name_ fleet::exact:: hooks::executor: 161 passed, 0 failed,
including workspace_fleets_load_from_dot_codewhale_and_the_legacy_root
and a_store_saved_workspace_fleet_is_found_by_load_fleet_document. This
commit's HEAD-based variant (2-arg load_fleet_document, no peer v2
bridge) was not compiled separately.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01TvKGZz4LrKfrQiAXip48JS
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-22 20:13:26 -07:00
1 parent 7e5793a29e
commit 26cfaf8de4
3 files changed
+101 -6

No files matched your search

+91 -1
View File
@@ -68,13 +68,25 @@ pub(crate) fn personal_fleet_definitions_dir() -> anyhow::Result<std::path::Path
Ok(personal_fleet_root()?.join("fleets"))
}
/// The workspace has two origins. `workspace` is `<workspace>/.codewhale`, the
/// directory the Fleet store saves workspace-scoped Fleets to, so a Fleet
/// saved from the Fleet UI is found by name. `workspace_root` is the workspace
/// directory itself, which keeps checked-in `fleets/<name>.toml` rosters
/// loading as they always have.
#[must_use]
pub(crate) fn fleet_search_roots(workspace: &std::path::Path) -> Vec<FleetSearchRoot> {
let mut roots = Vec::new();
if let Ok(home) = personal_fleet_root() {
roots.push(FleetSearchRoot::new("codewhale_home", home));
}
roots.push(FleetSearchRoot::new("workspace", workspace.to_path_buf()));
roots.push(FleetSearchRoot::new(
"workspace",
workspace.join(".codewhale"),
));
roots.push(FleetSearchRoot::new(
"workspace_root",
workspace.to_path_buf(),
));
roots
}
@@ -2812,4 +2824,82 @@ permissions = "read_only"
assert!(line.contains("(role auditor)"), "{line}");
assert!(line.contains("posture=explore"), "{line}");
}
// ── Search roots: where a workspace Fleet lives ────────────────────────
/// The Fleet store saves workspace Fleets under `<workspace>/.codewhale`,
/// so that is the primary `workspace` origin; the workspace root stays a
/// second origin for checked-in `fleets/<name>.toml` rosters.
#[test]
fn workspace_fleets_load_from_dot_codewhale_and_the_legacy_root() {
let _lock = crate::test_support::lock_test_env();
let home = tempfile::tempdir().expect("home");
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
let ws = tempfile::tempdir().expect("workspace");
let saved = ws.path().join(".codewhale").join("fleets");
std::fs::create_dir_all(&saved).expect("saved fleets dir");
std::fs::write(saved.join("glm-pair.toml"), GLM_FLEET).expect("write saved");
let (document, id) = load_fleet_document("glm-pair", ws.path()).expect("saved fleet loads");
assert_eq!(document.name(), "glm-pair");
assert_eq!(id.origin, "workspace");
let checked_in = ws.path().join("fleets");
std::fs::create_dir_all(&checked_in).expect("checked-in fleets dir");
std::fs::write(
checked_in.join("stopship.toml"),
"name = \"stopship\"\n\n[roles]\nscout = \"scout\"\n",
)
.expect("write checked-in");
let (document, id) =
load_fleet_document("stopship", ws.path()).expect("checked-in fleet still loads");
assert_eq!(document.name(), "stopship");
assert_eq!(id.origin, "workspace_root");
// An exact Fleet in both workspace origins is ambiguous, and each
// origin can be named explicitly.
std::fs::write(checked_in.join("glm-pair.toml"), GLM_FLEET).expect("write twin");
assert!(matches!(
load_fleet_document("glm-pair", ws.path()),
Err(NamedFleetError::AmbiguousFleet { .. })
));
let (_, id) = load_fleet_document("workspace_root/glm-pair", ws.path()).expect("qualified");
assert_eq!(id.origin, "workspace_root");
}
/// A Fleet saved through the store at workspace scope is found by the
/// Workflow loader instead of being reported missing. Today the store's
/// `schema = "fleet"` revision-2 document is not a schema the Workflow
/// loader parses, so the load names that exact file and its schema; if a
/// v2 bridge lands, the same call succeeds from the `workspace` origin.
#[test]
fn a_store_saved_workspace_fleet_is_found_by_load_fleet_document() {
use crate::fleet::store::{FleetFile, FleetScope, save_fleet};
let _lock = crate::test_support::lock_test_env();
let home = tempfile::tempdir().expect("home");
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
let ws = tempfile::tempdir().expect("workspace");
let fleet = FleetFile::new("Folder Pair".to_string(), None).expect("fleet");
let path = save_fleet(&fleet, FleetScope::Workspace, ws.path()).expect("save");
match load_fleet_document(&fleet.file_slug(), ws.path()) {
// A v2 bridge may label the store scope `folder` rather than the
// `workspace` search-root origin; either names this workspace.
Ok((_, id)) => assert!(
matches!(id.origin.as_str(), "workspace" | "folder"),
"{}",
id.origin
),
Err(err) => {
assert!(
!matches!(err, NamedFleetError::NotFound(_)),
"the saved Fleet must be found, got {err}"
);
let message = err.to_string();
assert!(message.contains(&path.display().to_string()), "{message}");
}
}
}
}
+1 -1
View File
@@ -1106,7 +1106,7 @@ impl ToolSpec for WorkflowTool {
},
"fleet": {
"type": "string",
"description": "Named Fleet from $CODEWHALE_HOME/fleets/ or workspace fleets/; qualified origin/name accepted. Exact Fleets freeze member identity, route, and reasoning. Runtime derives authority from role and live parent; per-task route/authority overrides are rejected."
"description": "Named Fleet from $CODEWHALE_HOME/fleets/, <workspace>/.codewhale/fleets/, or checked-in <workspace>/fleets/; qualified origin/name accepted (codewhale_home, workspace, workspace_root). Exact Fleets freeze member identity, route, and reasoning. Runtime derives authority from role and live parent; per-task route/authority overrides are rejected."
},
"plan": plan_schema::structured_plan_schema(),
"args": {
+9 -4
View File
@@ -26,7 +26,9 @@ existing workspaces, receipts, or scripts:
- the durable ledger `.codewhale/fleet.jsonl` and the log directories
`.codewhale/fleet/` and `.codewhale/fleet-host/`;
- saved rosters `fleets/<name>.toml` and their `schema = "fleet"` header;
- saved rosters `fleets/<name>.toml` and their `schema = "fleet"` header, under
`$CODEWHALE_HOME/` or the workspace's `.codewhale/` (checked-in rosters at the
workspace root's `fleets/` are still read);
- the `[fleet]` config table (inline `[fleets.*]` tables were removed in 0.9.14; named fleets live in `fleets/<name>.toml` files);
- the `codewhale workflow run --fleet <name>` flag;
- wire, receipt, and control-plane operation ids such as `fleet.status`.
@@ -314,7 +316,9 @@ header/status signal; avoid repeating emoji-heavy rows for every worker.
A selected v2 fleet freezes each selected member's id, semantic role, provider,
and model identity into the durable run before a Workflow starts. Save the
fleet as `fleets/<name>.toml` in the workspace or under `$CODEWHALE_HOME`.
fleet as `fleets/<name>.toml` under the workspace's `.codewhale/` (where the
fleet editor saves folder fleets) or under `$CODEWHALE_HOME`; a checked-in
`fleets/<name>.toml` at the workspace root is also read.
Models cannot replace those identity or route assignments at runtime:
```toml
@@ -364,8 +368,9 @@ Router call itself is capped at `off` or `low`; more expensive values are
rejected. A manually selected worker reasoning tier makes no Router call. Route
and reasoning receipts name the worker model and, when used, the Router's exact
provider/model so the operator can see which model did which job. If the same
bare Router or fleet name exists in both roots, qualify it as
`workspace/<name>` or `codewhale_home/<name>` instead of relying on shadowing.
bare Router or fleet name exists in more than one root, qualify it as
`codewhale_home/<name>`, `workspace/<name>` (the workspace's `.codewhale/`), or
`workspace_root/<name>` (the workspace root) instead of relying on shadowing.
Compatibility schemas may serialize `reasoning`, `permissions`, tool hints, or
other execution settings beside a member. Those values are not fleet identity,