whaleflow: bound fleet workflow shape

Add Fleet launch-shape validation for WhaleFlow IR with a 100-agent default population cap, five recursive rings, and bounded loop/expand requirements. Update the Fleet setup surface and docs to frame Fleet as the durable sub-agent config layer while WhaleFlow owns agent-authored orchestration.
This commit is contained in:
Hunter B
2026-06-24 20:02:59 -07:00
parent 500ce59bf7
commit 03146cbb92
6 changed files with 418 additions and 22 deletions

View File

@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Added WhaleFlow-to-Fleet launch-shape validation: the default Fleet workflow
contract allows up to 100 total agents and 5 recursive rings, requires
bounded loops/expands before launch, and preserves per-slot model selection.
### Changed
- Clarified the Fleet setup surface and docs so Fleet is treated as the durable
sub-agent configuration layer while WhaleFlow is the agent-authored
orchestration plan that selects and monitors Fleet slots.
## [0.8.65] - 2026-06-24
### Added

View File

@@ -391,6 +391,12 @@ fn build_lanes(snapshot: &FleetSetupSnapshot) -> Vec<FleetSetupLane> {
FleetSetupRow::new("fast", "scout", "opt-in low-latency fanout"),
FleetSetupRow::new("balanced", "auto class", "normal build/review when chosen")
.tone(RowTone::Ready),
FleetSetupRow::new(
"preset",
"editable",
"recommended tiers never force the orchestrator",
)
.tone(RowTone::Current),
FleetSetupRow::new("strong", "hard", "security, release, architecture"),
FleetSetupRow::new(
"fixed model",
@@ -459,6 +465,18 @@ fn build_lanes(snapshot: &FleetSetupSnapshot) -> Vec<FleetSetupLane> {
title: "5 Org",
subtitle: "team and recursion",
rows: vec![
FleetSetupRow::new(
"Fleet config",
"sub-agents",
"durable slots, profiles, models, tools, ledger",
)
.tone(RowTone::Current),
FleetSetupRow::new(
"WhaleFlow",
"agent plan",
"agent-authored workflow selects and monitors slots",
)
.tone(RowTone::Ready),
FleetSetupRow::new(
"role workers",
if snapshot.subagents_enabled {
@@ -486,25 +504,20 @@ fn build_lanes(snapshot: &FleetSetupSnapshot) -> Vec<FleetSetupLane> {
)
.tone(RowTone::Ready),
FleetSetupRow::new(
"starter team",
"3 scout + 1 each",
"builder, reviewer, verifier, synthesizer, operator",
"slot grid",
"1-5 slots",
"add or drill right into a recursive ring",
)
.tone(RowTone::Ready),
FleetSetupRow::new(
"scout tree",
"3 scouts",
"recursive exploration splits breadth-first",
"limits",
"100 total / depth 5",
"workflow population cap, separate from launch concurrency",
),
FleetSetupRow::new(
"builder tree",
"builder+reviewer",
"implementation gets paired review by default",
),
FleetSetupRow::new(
"verifier tree",
"verifier+reviewer",
"test evidence gets interpreted before handoff",
"DeepSeek preset",
"Pro -> Flash",
"editable per slot; cheaper as rings expand",
),
FleetSetupRow::new(
"budget",
@@ -697,9 +710,12 @@ fn profile_authoring_prompt(
Do not include provider, base_url, api_key, auth, secrets, trust, allow_shell, or approval_required=false.\n\
If model is present, keep it to a visible model id such as deepseek-v4-pro or glm-5.2.\n\
Fleet product shape:\n\
- Fleet is the durable sub-agent config surface: slots, profiles, models, tools, and ledger\n\
- one main orchestrator profile coordinates the Fleet run and verifies returned claims\n\
- workers are summoned as focused Fleet members with only their assigned slice\n\
- default model behavior is same-route inheritance; choose fast/strong/code/review only when the role needs it\n\
- DeepSeek-style model tiers are recommendations, not hierarchy rules; every slot may override model\n\
- WhaleFlow plans may select and monitor Fleet slots, but Fleet owns the worker config\n\
- do not encode a recursive worker tree in [instructions].text; topology belongs to the orchestrator, not each worker\n\n\
Keep the profile permission-narrowing and compatible with recursive Fleet role workers.",
provider = snapshot.provider,
@@ -767,8 +783,11 @@ mod tests {
assert!(text.contains("provider = DeepSeek"));
assert!(text.contains("model (optional explicit model id"));
assert!(text.contains("Do not include provider, base_url"));
assert!(text.contains("Fleet is the durable sub-agent config surface"));
assert!(text.contains("workers are summoned as focused Fleet members"));
assert!(text.contains("default model behavior is same-route inheritance"));
assert!(text.contains("model tiers are recommendations"));
assert!(text.contains("Fleet owns the worker config"));
assert!(text.contains("topology belongs to the orchestrator"));
}
other => panic!("expected profile prompt insertion, got {other:?}"),
@@ -811,6 +830,10 @@ mod tests {
assert!(view.profile_prompt().contains("Current route context only"));
assert!(view.profile_prompt().contains("permission-narrowing"));
assert!(view.profile_prompt().contains("same-route inheritance"));
assert!(
view.profile_prompt()
.contains("durable sub-agent config surface")
);
assert!(
view.profile_prompt()
.contains("topology belongs to the orchestrator")
@@ -833,7 +856,13 @@ mod tests {
view.lanes
.iter()
.flat_map(|lane| lane.rows.iter())
.any(|row| row.label == "starter team" && row.value == "3 scout + 1 each")
.any(|row| row.label == "slot grid" && row.value == "1-5 slots")
);
assert!(
view.lanes
.iter()
.flat_map(|lane| lane.rows.iter())
.any(|row| row.label == "limits" && row.value == "100 total / depth 5")
);
view.render(Rect::new(0, 0, 120, 32), &mut buf);
let rendered = buf
@@ -844,5 +873,6 @@ mod tests {
assert!(rendered.contains("Fleet"));
assert!(rendered.contains("recursion"));
assert!(rendered.contains("WhaleFlow"));
}
}

View File

@@ -27,6 +27,9 @@ pub use starlark_authoring::{
compile_starlark_workflow, compile_starlark_workflow_with_repair, repair_starlark_workflow_once,
};
pub const DEFAULT_FLEET_WORKFLOW_MAX_AGENTS: usize = 100;
pub const DEFAULT_FLEET_WORKFLOW_MAX_DEPTH: usize = 5;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkflowConfig {
pub goal: String,
@@ -67,6 +70,34 @@ pub struct WorkflowSpec {
pub nodes: Vec<WorkflowNode>,
}
impl WorkflowSpec {
pub fn validate_for_fleet(&self) -> Result<WorkflowFleetShape, WorkflowFleetLimitError> {
self.validate_for_fleet_with_limits(WorkflowFleetLimits::default())
}
pub fn validate_for_fleet_with_limits(
&self,
limits: WorkflowFleetLimits,
) -> Result<WorkflowFleetShape, WorkflowFleetLimitError> {
validate_workflow_nodes(&self.nodes)
.map_err(|source| WorkflowFleetLimitError::InvalidWorkflow { source })?;
let shape = estimate_fleet_shape(&self.nodes)?;
if shape.total_agents > limits.max_total_agents {
return Err(WorkflowFleetLimitError::TooManyAgents {
total_agents: shape.total_agents,
max_total_agents: limits.max_total_agents,
});
}
if shape.max_depth > limits.max_depth {
return Err(WorkflowFleetLimitError::RecursionTooDeep {
depth: shape.max_depth,
max_depth: limits.max_depth,
});
}
Ok(shape)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "spec", rename_all = "snake_case")]
pub enum WorkflowNode {
@@ -1481,6 +1512,127 @@ pub enum WorkflowExecutionError {
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WorkflowFleetLimits {
pub max_total_agents: usize,
pub max_depth: usize,
}
impl Default for WorkflowFleetLimits {
fn default() -> Self {
Self {
max_total_agents: DEFAULT_FLEET_WORKFLOW_MAX_AGENTS,
max_depth: DEFAULT_FLEET_WORKFLOW_MAX_DEPTH,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct WorkflowFleetShape {
pub total_agents: usize,
pub max_depth: usize,
}
impl WorkflowFleetShape {
fn add(self, other: Self) -> Self {
Self {
total_agents: self.total_agents.saturating_add(other.total_agents),
max_depth: self.max_depth.max(other.max_depth),
}
}
fn repeat(self, times: usize) -> Self {
Self {
total_agents: self.total_agents.saturating_mul(times),
max_depth: self.max_depth,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum WorkflowFleetLimitError {
#[error("workflow IR is invalid for Fleet: {source}")]
InvalidWorkflow {
#[from]
source: WorkflowExecutionError,
},
#[error(
"workflow would launch {total_agents} agents; Fleet WhaleFlow limit is {max_total_agents}"
)]
TooManyAgents {
total_agents: usize,
max_total_agents: usize,
},
#[error("workflow reaches recursion depth {depth}; Fleet WhaleFlow limit is {max_depth}")]
RecursionTooDeep { depth: usize, max_depth: usize },
#[error("expand node `{node}` must declare max_children before Fleet launch")]
UnboundedExpand { node: String },
#[error("expand node `{node}` must include a template before Fleet launch")]
MissingExpandTemplate { node: String },
#[error("loop_until node `{node}` must declare max_iterations before Fleet launch")]
UnboundedLoop { node: String },
}
fn estimate_fleet_shape(
nodes: &[WorkflowNode],
) -> Result<WorkflowFleetShape, WorkflowFleetLimitError> {
estimate_fleet_shape_at_depth(nodes, 1)
}
fn estimate_fleet_shape_at_depth(
nodes: &[WorkflowNode],
depth: usize,
) -> Result<WorkflowFleetShape, WorkflowFleetLimitError> {
nodes
.iter()
.try_fold(WorkflowFleetShape::default(), |shape, node| {
Ok(shape.add(estimate_node_fleet_shape(node, depth)?))
})
}
fn estimate_node_fleet_shape(
node: &WorkflowNode,
depth: usize,
) -> Result<WorkflowFleetShape, WorkflowFleetLimitError> {
match node {
WorkflowNode::Leaf(_) => Ok(WorkflowFleetShape {
total_agents: 1,
max_depth: depth,
}),
WorkflowNode::BranchSet(spec) => estimate_fleet_shape_at_depth(&spec.children, depth + 1),
WorkflowNode::Sequence(spec) => estimate_fleet_shape_at_depth(&spec.children, depth),
WorkflowNode::Reduce(_) | WorkflowNode::TeacherReview(_) => Ok(WorkflowFleetShape {
total_agents: 0,
max_depth: 0,
}),
WorkflowNode::LoopUntil(spec) => {
let iterations =
spec.max_iterations
.ok_or_else(|| WorkflowFleetLimitError::UnboundedLoop {
node: spec.id.clone(),
})? as usize;
Ok(estimate_fleet_shape_at_depth(&spec.children, depth)?.repeat(iterations.max(1)))
}
WorkflowNode::Cond(spec) => Ok(estimate_fleet_shape_at_depth(&spec.then_nodes, depth)?
.add(estimate_fleet_shape_at_depth(&spec.else_nodes, depth)?)),
WorkflowNode::Expand(spec) => {
let max_children =
spec.max_children
.ok_or_else(|| WorkflowFleetLimitError::UnboundedExpand {
node: spec.id.clone(),
})?;
let template = spec.template.as_deref().ok_or_else(|| {
WorkflowFleetLimitError::MissingExpandTemplate {
node: spec.id.clone(),
}
})?;
validate_workflow_node_shapes(std::slice::from_ref(template))
.map_err(|source| WorkflowFleetLimitError::InvalidWorkflow { source })?;
Ok(estimate_node_fleet_shape(template, depth)?.repeat(max_children))
}
}
}
fn default_frontier_limit() -> usize {
8
}
@@ -2308,6 +2460,149 @@ mod tests {
assert_eq!(minimal.model_policy, ModelPolicy::default());
}
#[test]
fn fleet_validation_accepts_one_hundred_agents_and_variable_models() {
let nodes = (0..DEFAULT_FLEET_WORKFLOW_MAX_AGENTS)
.map(|index| {
let mut leaf = match leaf_node(&format!("agent-{index}")) {
WorkflowNode::Leaf(leaf) => leaf,
_ => unreachable!("leaf helper returns a leaf"),
};
leaf.model_policy = if index == 0 {
ModelPolicy {
provider: Some("deepseek".to_string()),
model: Some("deepseek-v4-pro".to_string()),
fallback_models: Vec::new(),
}
} else {
ModelPolicy {
provider: Some("deepseek".to_string()),
model: Some("deepseek-v4-flash".to_string()),
fallback_models: Vec::new(),
}
};
WorkflowNode::Leaf(leaf)
})
.collect();
let workflow = workflow_spec(nodes);
let shape = workflow
.validate_for_fleet()
.expect("one hundred agents should fit the Fleet WhaleFlow limit");
assert_eq!(shape.total_agents, DEFAULT_FLEET_WORKFLOW_MAX_AGENTS);
assert_eq!(shape.max_depth, 1);
}
#[test]
fn fleet_validation_rejects_more_than_one_hundred_agents() {
let nodes = (0..=DEFAULT_FLEET_WORKFLOW_MAX_AGENTS)
.map(|index| leaf_node(&format!("agent-{index}")))
.collect();
let workflow = workflow_spec(nodes);
let err = workflow
.validate_for_fleet()
.expect_err("agent population should be bounded before Fleet launch");
assert_eq!(
err,
WorkflowFleetLimitError::TooManyAgents {
total_agents: DEFAULT_FLEET_WORKFLOW_MAX_AGENTS + 1,
max_total_agents: DEFAULT_FLEET_WORKFLOW_MAX_AGENTS,
}
);
}
#[test]
fn fleet_validation_rejects_depth_beyond_five() {
let mut node = leaf_node("deep-leaf");
for depth in (0..DEFAULT_FLEET_WORKFLOW_MAX_DEPTH).rev() {
node = WorkflowNode::BranchSet(BranchSpec {
id: format!("ring-{depth}"),
description: None,
parallel: true,
budget: BudgetSpec::default(),
permissions: PermissionSpec::default(),
model_policy: ModelPolicy::default(),
children: vec![node],
});
}
let workflow = workflow_spec(vec![node]);
let err = workflow
.validate_for_fleet()
.expect_err("sixth agent ring should be rejected");
assert_eq!(
err,
WorkflowFleetLimitError::RecursionTooDeep {
depth: DEFAULT_FLEET_WORKFLOW_MAX_DEPTH + 1,
max_depth: DEFAULT_FLEET_WORKFLOW_MAX_DEPTH,
}
);
}
#[test]
fn fleet_validation_counts_loop_and_expand_fanout_conservatively() {
let workflow = workflow_spec(vec![
WorkflowNode::LoopUntil(LoopUntilSpec {
id: "retry-ring".to_string(),
condition: "verifier passes".to_string(),
max_iterations: Some(3),
children: vec![leaf_node("retry-worker")],
}),
WorkflowNode::Expand(ExpandSpec {
id: "split".to_string(),
source: "retry-ring".to_string(),
max_children: Some(4),
template: Some(Box::new(leaf_node("split-template"))),
}),
]);
let shape = workflow
.validate_for_fleet()
.expect("bounded loop and expand should validate");
assert_eq!(shape.total_agents, 7);
assert_eq!(shape.max_depth, 1);
}
#[test]
fn fleet_validation_rejects_unbounded_loop_or_expand_before_launch() {
let workflow = workflow_spec(vec![
WorkflowNode::LoopUntil(LoopUntilSpec {
id: "retry-ring".to_string(),
condition: "verifier passes".to_string(),
max_iterations: None,
children: vec![leaf_node("retry-worker")],
}),
WorkflowNode::Expand(ExpandSpec {
id: "split".to_string(),
source: "retry-ring".to_string(),
max_children: Some(4),
template: Some(Box::new(leaf_node("split-template"))),
}),
]);
assert!(matches!(
workflow.validate_for_fleet(),
Err(WorkflowFleetLimitError::UnboundedLoop { node }) if node == "retry-ring"
));
let workflow = workflow_spec(vec![WorkflowNode::Expand(ExpandSpec {
id: "split".to_string(),
source: "retry-ring".to_string(),
max_children: None,
template: Some(Box::new(leaf_node("split-template"))),
})]);
assert!(matches!(
workflow.validate_for_fleet(),
Err(WorkflowFleetLimitError::UnboundedExpand { node }) if node == "split"
));
}
#[test]
fn branch_result_serialization() {
let result = BranchResult {

View File

@@ -47,8 +47,9 @@ can run on top of those modes when the task needs a continuous workflow.
intermediate results out of the main conversation, and can be inspected or
rerun. A WhaleFlow run should have a visible progress view and a clear active
header state instead of feeling like a hidden background task.
- **Fleet** is the execution substrate: headless workers, local/SSH hosts,
trust policy, leases, heartbeats, logs, receipts, and status APIs.
- **Fleet** is the durable sub-agent configuration and execution substrate:
slots, profiles, per-slot models, tool posture, local/SSH hosts, trust
policy, leases, heartbeats, logs, receipts, and status APIs.
- **Swarm** is the high-fanout behavior inside WhaleFlow. It is gated in
v0.8.61: `/swarm` must not revive prompt-only sub-agent fanout. It should
compile into a WhaleFlow-backed fleet run once the durable worker and goal
@@ -60,6 +61,37 @@ counts, receipts, and nested indentation for child workers. Use the whale mark
sparingly as an active header/status signal; avoid repeating emoji-heavy rows
for every worker.
## WhaleFlow on Fleet
The intended high-capability path is agent-authored. When the main agent
decides a task needs more durable coordination than turn-by-turn sub-agent
calls, it drafts a WhaleFlow script/IR, presents the run plan according to the
active permission mode, and the runtime compiles it into typed Fleet work.
Fleet remains the sub-agent config surface. It owns slot count, role profiles,
model/loadout selection, tool posture, launch concurrency, and the ledger.
WhaleFlow owns only the orchestration plan: branch, sequence, loop, expand,
review, and reduce decisions. The workflow script must not get direct shell,
filesystem, network, provider-secret, cancellation, or TUI authority; workers
perform real work as `codewhale exec` processes.
Default WhaleFlow-to-Fleet validation is intentionally bounded:
- 100 total worker agents per workflow run;
- 5 recursive Fleet rings;
- bounded loops only (`max_iterations` required);
- bounded dynamic expansion only (`max_children` plus a template required).
These are population limits, not a demand to launch everything at once. A
100-agent workflow should still drain through the configured Fleet worker pool.
Recommended model layouts, such as a DeepSeek Pro orchestrator with Flash
workers in the first ring and cheaper workers farther out, are presets only.
Every slot can inherit the active model or carry an explicit model override.
The setup UI should render this as an expanding grid: an orchestrator plus a
small number of visible sub-agent slots, with Right/Enter drilling into a slot's
next recursive ring rather than trying to show the whole tree at once.
## Task Spec
`codewhale fleet run` accepts JSON or TOML. A minimal JSON spec:
@@ -511,7 +543,7 @@ max_trust_level = "operator"
[fleet.exec]
# Recursion depth shares ONE axis with standalone sub-agents — a fleet worker
# IS a headless sub-agent. 0 blocks child agents (the root worker still runs);
# 3 is the default and the ceiling, affording at least three nested levels.
# 3 is the default; explicit config clamps to the shared safety ceiling.
max_spawn_depth = 3
```

View File

@@ -57,3 +57,31 @@ a familiar declaration format, not a second execution runtime.
- `cargo test -p codewhale-whaleflow --locked starlark`
Current example: `workflows/issue_audit.workflow.js`.
## Agent-Written Fleet Workflows
The primary product flow is not "ask the user to write a script." The main
agent should decide when a task deserves workflow orchestration, draft the
WhaleFlow source, show the plan for the current permission mode, and then let
the runtime compile and monitor it.
WhaleFlow owns the plan: phases, branches, loops, reducers, and intermediate
results. Fleet owns the durable sub-agent configuration: slots, profiles,
models, tool posture, launch concurrency, leases, heartbeats, logs, receipts,
and resume/stop/restart controls. In other words, a workflow can choose and
monitor Fleet slots, but it must not become a second executor with its own shell
or filesystem authority.
Fleet launch validation applies a conservative default shape before any
WhaleFlow IR is lowered to workers:
- up to 100 total worker agents per workflow run;
- up to 5 recursive Fleet rings;
- loops require `max_iterations`;
- dynamic `expand` nodes require `max_children` and a template.
Those limits bound the workflow population, not instantaneous launch
concurrency. A valid 100-agent workflow can still drain through a smaller Fleet
worker pool. Model selection stays per slot: a DeepSeek preset can suggest
`deepseek-v4-pro` for the orchestrator and `deepseek-v4-flash` for nearby
workers, but users and agents may override any slot when the task calls for it.

View File

@@ -18,10 +18,9 @@
# codewhale fleet inspect <worker-id-from-status>
# codewhale fleet logs <worker-id-from-status>
#
# NOTE: wiring the manager run loop to drive FleetExecutor for real workers is
# the in-progress cutover; until then the manual run path uses the local
# simulation harness. The automated smoke above already proves the real
# exec-subprocess -> ledger-event path.
# NOTE: this manual run path now drives real `codewhale exec` workers through
# the FleetExecutor. Use `--once` when you only want to enqueue/lease once and
# inspect state manually instead of keeping the manager loop attached.
name = "dogfood smoke"
labels = { milestone = "v0.8.60", class = "smoke" }