mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-10 17:59:43 +08:00
feat: add ecc2 agent profiles
This commit is contained in:
parent
e48468a9e7
commit
1e4d6a4161
@ -1,6 +1,7 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::notifications::{
|
use crate::notifications::{
|
||||||
@ -48,6 +49,35 @@ pub struct ConflictResolutionConfig {
|
|||||||
pub notify_lead: bool,
|
pub notify_lead: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct AgentProfileConfig {
|
||||||
|
pub inherits: Option<String>,
|
||||||
|
pub agent: Option<String>,
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub allowed_tools: Vec<String>,
|
||||||
|
pub disallowed_tools: Vec<String>,
|
||||||
|
pub permission_mode: Option<String>,
|
||||||
|
pub add_dirs: Vec<PathBuf>,
|
||||||
|
pub max_budget_usd: Option<f64>,
|
||||||
|
pub token_budget: Option<u64>,
|
||||||
|
pub append_system_prompt: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ResolvedAgentProfile {
|
||||||
|
pub profile_name: String,
|
||||||
|
pub agent: Option<String>,
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub allowed_tools: Vec<String>,
|
||||||
|
pub disallowed_tools: Vec<String>,
|
||||||
|
pub permission_mode: Option<String>,
|
||||||
|
pub add_dirs: Vec<PathBuf>,
|
||||||
|
pub max_budget_usd: Option<f64>,
|
||||||
|
pub token_budget: Option<u64>,
|
||||||
|
pub append_system_prompt: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
@ -61,6 +91,8 @@ pub struct Config {
|
|||||||
pub heartbeat_interval_secs: u64,
|
pub heartbeat_interval_secs: u64,
|
||||||
pub auto_terminate_stale_sessions: bool,
|
pub auto_terminate_stale_sessions: bool,
|
||||||
pub default_agent: String,
|
pub default_agent: String,
|
||||||
|
pub default_agent_profile: Option<String>,
|
||||||
|
pub agent_profiles: BTreeMap<String, AgentProfileConfig>,
|
||||||
pub auto_dispatch_unread_handoffs: bool,
|
pub auto_dispatch_unread_handoffs: bool,
|
||||||
pub auto_dispatch_limit_per_session: usize,
|
pub auto_dispatch_limit_per_session: usize,
|
||||||
pub auto_create_worktrees: bool,
|
pub auto_create_worktrees: bool,
|
||||||
@ -122,6 +154,8 @@ impl Default for Config {
|
|||||||
heartbeat_interval_secs: 30,
|
heartbeat_interval_secs: 30,
|
||||||
auto_terminate_stale_sessions: false,
|
auto_terminate_stale_sessions: false,
|
||||||
default_agent: "claude".to_string(),
|
default_agent: "claude".to_string(),
|
||||||
|
default_agent_profile: None,
|
||||||
|
agent_profiles: BTreeMap::new(),
|
||||||
auto_dispatch_unread_handoffs: false,
|
auto_dispatch_unread_handoffs: false,
|
||||||
auto_dispatch_limit_per_session: 5,
|
auto_dispatch_limit_per_session: 5,
|
||||||
auto_create_worktrees: true,
|
auto_create_worktrees: true,
|
||||||
@ -180,6 +214,41 @@ impl Config {
|
|||||||
self.budget_alert_thresholds.sanitized()
|
self.budget_alert_thresholds.sanitized()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn resolve_agent_profile(&self, name: &str) -> Result<ResolvedAgentProfile> {
|
||||||
|
let mut chain = Vec::new();
|
||||||
|
self.resolve_agent_profile_inner(name, &mut chain)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_agent_profile_inner(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
chain: &mut Vec<String>,
|
||||||
|
) -> Result<ResolvedAgentProfile> {
|
||||||
|
if chain.iter().any(|existing| existing == name) {
|
||||||
|
chain.push(name.to_string());
|
||||||
|
anyhow::bail!(
|
||||||
|
"agent profile inheritance cycle: {}",
|
||||||
|
chain.join(" -> ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let profile = self
|
||||||
|
.agent_profiles
|
||||||
|
.get(name)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Unknown agent profile: {name}"))?;
|
||||||
|
|
||||||
|
chain.push(name.to_string());
|
||||||
|
let mut resolved = if let Some(parent) = profile.inherits.as_deref() {
|
||||||
|
self.resolve_agent_profile_inner(parent, chain)?
|
||||||
|
} else {
|
||||||
|
ResolvedAgentProfile::default()
|
||||||
|
};
|
||||||
|
chain.pop();
|
||||||
|
|
||||||
|
resolved.apply(name, profile);
|
||||||
|
Ok(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn load() -> Result<Self> {
|
pub fn load() -> Result<Self> {
|
||||||
let global_paths = Self::global_config_paths();
|
let global_paths = Self::global_config_paths();
|
||||||
let project_paths = std::env::current_dir()
|
let project_paths = std::env::current_dir()
|
||||||
@ -437,6 +506,50 @@ impl Default for ConflictResolutionConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ResolvedAgentProfile {
|
||||||
|
fn apply(&mut self, profile_name: &str, config: &AgentProfileConfig) {
|
||||||
|
self.profile_name = profile_name.to_string();
|
||||||
|
if let Some(agent) = config.agent.as_ref() {
|
||||||
|
self.agent = Some(agent.clone());
|
||||||
|
}
|
||||||
|
if let Some(model) = config.model.as_ref() {
|
||||||
|
self.model = Some(model.clone());
|
||||||
|
}
|
||||||
|
merge_unique(&mut self.allowed_tools, &config.allowed_tools);
|
||||||
|
merge_unique(&mut self.disallowed_tools, &config.disallowed_tools);
|
||||||
|
if let Some(permission_mode) = config.permission_mode.as_ref() {
|
||||||
|
self.permission_mode = Some(permission_mode.clone());
|
||||||
|
}
|
||||||
|
merge_unique(&mut self.add_dirs, &config.add_dirs);
|
||||||
|
if let Some(max_budget_usd) = config.max_budget_usd {
|
||||||
|
self.max_budget_usd = Some(max_budget_usd);
|
||||||
|
}
|
||||||
|
if let Some(token_budget) = config.token_budget {
|
||||||
|
self.token_budget = Some(token_budget);
|
||||||
|
}
|
||||||
|
self.append_system_prompt = match (
|
||||||
|
self.append_system_prompt.take(),
|
||||||
|
config.append_system_prompt.as_ref(),
|
||||||
|
) {
|
||||||
|
(Some(parent), Some(child)) => Some(format!("{parent}\n\n{child}")),
|
||||||
|
(Some(parent), None) => Some(parent),
|
||||||
|
(None, Some(child)) => Some(child.clone()),
|
||||||
|
(None, None) => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_unique<T>(base: &mut Vec<T>, additions: &[T])
|
||||||
|
where
|
||||||
|
T: Clone + PartialEq,
|
||||||
|
{
|
||||||
|
for value in additions {
|
||||||
|
if !base.contains(value) {
|
||||||
|
base.push(value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl BudgetAlertThresholds {
|
impl BudgetAlertThresholds {
|
||||||
pub fn sanitized(self) -> Self {
|
pub fn sanitized(self) -> Self {
|
||||||
let values = [self.advisory, self.warning, self.critical];
|
let values = [self.advisory, self.warning, self.critical];
|
||||||
@ -461,6 +574,7 @@ mod tests {
|
|||||||
PaneLayout,
|
PaneLayout,
|
||||||
};
|
};
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use std::path::PathBuf;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -806,6 +920,65 @@ notify_lead = false
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_profiles_resolve_inheritance_and_defaults() {
|
||||||
|
let config: Config = toml::from_str(
|
||||||
|
r#"
|
||||||
|
default_agent_profile = "reviewer"
|
||||||
|
|
||||||
|
[agent_profiles.base]
|
||||||
|
model = "sonnet"
|
||||||
|
allowed_tools = ["Read"]
|
||||||
|
permission_mode = "plan"
|
||||||
|
add_dirs = ["docs"]
|
||||||
|
append_system_prompt = "Be careful."
|
||||||
|
|
||||||
|
[agent_profiles.reviewer]
|
||||||
|
inherits = "base"
|
||||||
|
allowed_tools = ["Edit"]
|
||||||
|
disallowed_tools = ["Bash"]
|
||||||
|
token_budget = 1200
|
||||||
|
append_system_prompt = "Review thoroughly."
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let profile = config.resolve_agent_profile("reviewer").unwrap();
|
||||||
|
assert_eq!(config.default_agent_profile.as_deref(), Some("reviewer"));
|
||||||
|
assert_eq!(profile.profile_name, "reviewer");
|
||||||
|
assert_eq!(profile.model.as_deref(), Some("sonnet"));
|
||||||
|
assert_eq!(profile.allowed_tools, vec!["Read", "Edit"]);
|
||||||
|
assert_eq!(profile.disallowed_tools, vec!["Bash"]);
|
||||||
|
assert_eq!(profile.permission_mode.as_deref(), Some("plan"));
|
||||||
|
assert_eq!(profile.add_dirs, vec![PathBuf::from("docs")]);
|
||||||
|
assert_eq!(profile.token_budget, Some(1200));
|
||||||
|
assert_eq!(
|
||||||
|
profile.append_system_prompt.as_deref(),
|
||||||
|
Some("Be careful.\n\nReview thoroughly.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_profile_resolution_rejects_inheritance_cycles() {
|
||||||
|
let config: Config = toml::from_str(
|
||||||
|
r#"
|
||||||
|
[agent_profiles.a]
|
||||||
|
inherits = "b"
|
||||||
|
|
||||||
|
[agent_profiles.b]
|
||||||
|
inherits = "a"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let error = config
|
||||||
|
.resolve_agent_profile("a")
|
||||||
|
.expect_err("profile inheritance cycles must fail");
|
||||||
|
assert!(error
|
||||||
|
.to_string()
|
||||||
|
.contains("agent profile inheritance cycle"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn completion_summary_notifications_deserialize_from_toml() {
|
fn completion_summary_notifications_deserialize_from_toml() {
|
||||||
let config: Config = toml::from_str(
|
let config: Config = toml::from_str(
|
||||||
|
|||||||
@ -53,6 +53,9 @@ enum Commands {
|
|||||||
/// Agent type (claude, codex, custom)
|
/// Agent type (claude, codex, custom)
|
||||||
#[arg(short, long, default_value = "claude")]
|
#[arg(short, long, default_value = "claude")]
|
||||||
agent: String,
|
agent: String,
|
||||||
|
/// Agent profile defined in ecc2.toml
|
||||||
|
#[arg(long)]
|
||||||
|
profile: Option<String>,
|
||||||
#[command(flatten)]
|
#[command(flatten)]
|
||||||
worktree: WorktreePolicyArgs,
|
worktree: WorktreePolicyArgs,
|
||||||
/// Source session to delegate from
|
/// Source session to delegate from
|
||||||
@ -69,6 +72,9 @@ enum Commands {
|
|||||||
/// Agent type (claude, codex, custom)
|
/// Agent type (claude, codex, custom)
|
||||||
#[arg(short, long, default_value = "claude")]
|
#[arg(short, long, default_value = "claude")]
|
||||||
agent: String,
|
agent: String,
|
||||||
|
/// Agent profile defined in ecc2.toml
|
||||||
|
#[arg(long)]
|
||||||
|
profile: Option<String>,
|
||||||
#[command(flatten)]
|
#[command(flatten)]
|
||||||
worktree: WorktreePolicyArgs,
|
worktree: WorktreePolicyArgs,
|
||||||
},
|
},
|
||||||
@ -82,6 +88,9 @@ enum Commands {
|
|||||||
/// Agent type (claude, codex, custom)
|
/// Agent type (claude, codex, custom)
|
||||||
#[arg(short, long, default_value = "claude")]
|
#[arg(short, long, default_value = "claude")]
|
||||||
agent: String,
|
agent: String,
|
||||||
|
/// Agent profile defined in ecc2.toml
|
||||||
|
#[arg(long)]
|
||||||
|
profile: Option<String>,
|
||||||
#[command(flatten)]
|
#[command(flatten)]
|
||||||
worktree: WorktreePolicyArgs,
|
worktree: WorktreePolicyArgs,
|
||||||
},
|
},
|
||||||
@ -381,6 +390,7 @@ async fn main() -> Result<()> {
|
|||||||
Some(Commands::Start {
|
Some(Commands::Start {
|
||||||
task,
|
task,
|
||||||
agent,
|
agent,
|
||||||
|
profile,
|
||||||
worktree,
|
worktree,
|
||||||
from_session,
|
from_session,
|
||||||
}) => {
|
}) => {
|
||||||
@ -394,18 +404,34 @@ async fn main() -> Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let session_id = session::manager::create_session_with_grouping(
|
let grouping = session::SessionGrouping {
|
||||||
&db,
|
project: source.as_ref().map(|session| session.project.clone()),
|
||||||
&cfg,
|
task_group: source.as_ref().map(|session| session.task_group.clone()),
|
||||||
&task,
|
};
|
||||||
&agent,
|
let session_id = if let Some(source) = source.as_ref() {
|
||||||
use_worktree,
|
session::manager::create_session_from_source_with_profile_and_grouping(
|
||||||
session::SessionGrouping {
|
&db,
|
||||||
project: source.as_ref().map(|session| session.project.clone()),
|
&cfg,
|
||||||
task_group: source.as_ref().map(|session| session.task_group.clone()),
|
&task,
|
||||||
},
|
&agent,
|
||||||
)
|
use_worktree,
|
||||||
.await?;
|
profile.as_deref(),
|
||||||
|
&source.id,
|
||||||
|
grouping,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
session::manager::create_session_with_profile_and_grouping(
|
||||||
|
&db,
|
||||||
|
&cfg,
|
||||||
|
&task,
|
||||||
|
&agent,
|
||||||
|
use_worktree,
|
||||||
|
profile.as_deref(),
|
||||||
|
grouping,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
if let Some(source) = source {
|
if let Some(source) = source {
|
||||||
let from_id = source.id;
|
let from_id = source.id;
|
||||||
send_handoff_message(&db, &from_id, &session_id)?;
|
send_handoff_message(&db, &from_id, &session_id)?;
|
||||||
@ -416,6 +442,7 @@ async fn main() -> Result<()> {
|
|||||||
from_session,
|
from_session,
|
||||||
task,
|
task,
|
||||||
agent,
|
agent,
|
||||||
|
profile,
|
||||||
worktree,
|
worktree,
|
||||||
}) => {
|
}) => {
|
||||||
let use_worktree = worktree.resolve(&cfg);
|
let use_worktree = worktree.resolve(&cfg);
|
||||||
@ -431,12 +458,14 @@ async fn main() -> Result<()> {
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
let session_id = session::manager::create_session_with_grouping(
|
let session_id = session::manager::create_session_from_source_with_profile_and_grouping(
|
||||||
&db,
|
&db,
|
||||||
&cfg,
|
&cfg,
|
||||||
&task,
|
&task,
|
||||||
&agent,
|
&agent,
|
||||||
use_worktree,
|
use_worktree,
|
||||||
|
profile.as_deref(),
|
||||||
|
&source.id,
|
||||||
session::SessionGrouping {
|
session::SessionGrouping {
|
||||||
project: Some(source.project.clone()),
|
project: Some(source.project.clone()),
|
||||||
task_group: Some(source.task_group.clone()),
|
task_group: Some(source.task_group.clone()),
|
||||||
@ -454,13 +483,22 @@ async fn main() -> Result<()> {
|
|||||||
from_session,
|
from_session,
|
||||||
task,
|
task,
|
||||||
agent,
|
agent,
|
||||||
|
profile,
|
||||||
worktree,
|
worktree,
|
||||||
}) => {
|
}) => {
|
||||||
let use_worktree = worktree.resolve(&cfg);
|
let use_worktree = worktree.resolve(&cfg);
|
||||||
let lead_id = resolve_session_id(&db, &from_session)?;
|
let lead_id = resolve_session_id(&db, &from_session)?;
|
||||||
let outcome =
|
let outcome = session::manager::assign_session_with_profile_and_grouping(
|
||||||
session::manager::assign_session(&db, &cfg, &lead_id, &task, &agent, use_worktree)
|
&db,
|
||||||
.await?;
|
&cfg,
|
||||||
|
&lead_id,
|
||||||
|
&task,
|
||||||
|
&agent,
|
||||||
|
use_worktree,
|
||||||
|
profile.as_deref(),
|
||||||
|
session::SessionGrouping::default(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
if session::manager::assignment_action_routes_work(outcome.action) {
|
if session::manager::assignment_action_routes_work(outcome.action) {
|
||||||
println!(
|
println!(
|
||||||
"Assignment routed: {} -> {} ({})",
|
"Assignment routed: {} -> {} ({})",
|
||||||
|
|||||||
@ -11,7 +11,7 @@ use super::runtime::capture_command_output;
|
|||||||
use super::store::StateStore;
|
use super::store::StateStore;
|
||||||
use super::{
|
use super::{
|
||||||
default_project_label, default_task_group_label, normalize_group_label, Session,
|
default_project_label, default_task_group_label, normalize_group_label, Session,
|
||||||
SessionGrouping, SessionMetrics, SessionState,
|
SessionAgentProfile, SessionGrouping, SessionMetrics, SessionState,
|
||||||
};
|
};
|
||||||
use crate::comms::{self, MessageType};
|
use crate::comms::{self, MessageType};
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
@ -25,12 +25,13 @@ pub async fn create_session(
|
|||||||
agent_type: &str,
|
agent_type: &str,
|
||||||
use_worktree: bool,
|
use_worktree: bool,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
create_session_with_grouping(
|
create_session_with_profile_and_grouping(
|
||||||
db,
|
db,
|
||||||
cfg,
|
cfg,
|
||||||
task,
|
task,
|
||||||
agent_type,
|
agent_type,
|
||||||
use_worktree,
|
use_worktree,
|
||||||
|
None,
|
||||||
SessionGrouping::default(),
|
SessionGrouping::default(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@ -43,6 +44,27 @@ pub async fn create_session_with_grouping(
|
|||||||
agent_type: &str,
|
agent_type: &str,
|
||||||
use_worktree: bool,
|
use_worktree: bool,
|
||||||
grouping: SessionGrouping,
|
grouping: SessionGrouping,
|
||||||
|
) -> Result<String> {
|
||||||
|
create_session_with_profile_and_grouping(
|
||||||
|
db,
|
||||||
|
cfg,
|
||||||
|
task,
|
||||||
|
agent_type,
|
||||||
|
use_worktree,
|
||||||
|
None,
|
||||||
|
grouping,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_session_with_profile_and_grouping(
|
||||||
|
db: &StateStore,
|
||||||
|
cfg: &Config,
|
||||||
|
task: &str,
|
||||||
|
agent_type: &str,
|
||||||
|
use_worktree: bool,
|
||||||
|
profile_name: Option<&str>,
|
||||||
|
grouping: SessionGrouping,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let repo_root =
|
let repo_root =
|
||||||
std::env::current_dir().context("Failed to resolve current working directory")?;
|
std::env::current_dir().context("Failed to resolve current working directory")?;
|
||||||
@ -53,6 +75,34 @@ pub async fn create_session_with_grouping(
|
|||||||
agent_type,
|
agent_type,
|
||||||
use_worktree,
|
use_worktree,
|
||||||
&repo_root,
|
&repo_root,
|
||||||
|
profile_name,
|
||||||
|
None,
|
||||||
|
grouping,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_session_from_source_with_profile_and_grouping(
|
||||||
|
db: &StateStore,
|
||||||
|
cfg: &Config,
|
||||||
|
task: &str,
|
||||||
|
agent_type: &str,
|
||||||
|
use_worktree: bool,
|
||||||
|
profile_name: Option<&str>,
|
||||||
|
source_session_id: &str,
|
||||||
|
grouping: SessionGrouping,
|
||||||
|
) -> Result<String> {
|
||||||
|
let repo_root =
|
||||||
|
std::env::current_dir().context("Failed to resolve current working directory")?;
|
||||||
|
queue_session_in_dir(
|
||||||
|
db,
|
||||||
|
cfg,
|
||||||
|
task,
|
||||||
|
agent_type,
|
||||||
|
use_worktree,
|
||||||
|
&repo_root,
|
||||||
|
profile_name,
|
||||||
|
Some(source_session_id),
|
||||||
grouping,
|
grouping,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@ -66,6 +116,7 @@ pub fn get_status(db: &StateStore, id: &str) -> Result<SessionStatus> {
|
|||||||
let session = resolve_session(db, id)?;
|
let session = resolve_session(db, id)?;
|
||||||
let session_id = session.id.clone();
|
let session_id = session.id.clone();
|
||||||
Ok(SessionStatus {
|
Ok(SessionStatus {
|
||||||
|
profile: db.get_session_profile(&session_id)?,
|
||||||
session,
|
session,
|
||||||
parent_session: db.latest_task_handoff_source(&session_id)?,
|
parent_session: db.latest_task_handoff_source(&session_id)?,
|
||||||
delegated_children: db.delegated_children(&session_id, 5)?,
|
delegated_children: db.delegated_children(&session_id, 5)?,
|
||||||
@ -159,13 +210,14 @@ pub async fn assign_session(
|
|||||||
agent_type: &str,
|
agent_type: &str,
|
||||||
use_worktree: bool,
|
use_worktree: bool,
|
||||||
) -> Result<AssignmentOutcome> {
|
) -> Result<AssignmentOutcome> {
|
||||||
assign_session_with_grouping(
|
assign_session_with_profile_and_grouping(
|
||||||
db,
|
db,
|
||||||
cfg,
|
cfg,
|
||||||
lead_id,
|
lead_id,
|
||||||
task,
|
task,
|
||||||
agent_type,
|
agent_type,
|
||||||
use_worktree,
|
use_worktree,
|
||||||
|
None,
|
||||||
SessionGrouping::default(),
|
SessionGrouping::default(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@ -179,6 +231,29 @@ pub async fn assign_session_with_grouping(
|
|||||||
agent_type: &str,
|
agent_type: &str,
|
||||||
use_worktree: bool,
|
use_worktree: bool,
|
||||||
grouping: SessionGrouping,
|
grouping: SessionGrouping,
|
||||||
|
) -> Result<AssignmentOutcome> {
|
||||||
|
assign_session_with_profile_and_grouping(
|
||||||
|
db,
|
||||||
|
cfg,
|
||||||
|
lead_id,
|
||||||
|
task,
|
||||||
|
agent_type,
|
||||||
|
use_worktree,
|
||||||
|
None,
|
||||||
|
grouping,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn assign_session_with_profile_and_grouping(
|
||||||
|
db: &StateStore,
|
||||||
|
cfg: &Config,
|
||||||
|
lead_id: &str,
|
||||||
|
task: &str,
|
||||||
|
agent_type: &str,
|
||||||
|
use_worktree: bool,
|
||||||
|
profile_name: Option<&str>,
|
||||||
|
grouping: SessionGrouping,
|
||||||
) -> Result<AssignmentOutcome> {
|
) -> Result<AssignmentOutcome> {
|
||||||
let repo_root =
|
let repo_root =
|
||||||
std::env::current_dir().context("Failed to resolve current working directory")?;
|
std::env::current_dir().context("Failed to resolve current working directory")?;
|
||||||
@ -191,6 +266,7 @@ pub async fn assign_session_with_grouping(
|
|||||||
use_worktree,
|
use_worktree,
|
||||||
&repo_root,
|
&repo_root,
|
||||||
&std::env::current_exe().context("Failed to resolve ECC executable path")?,
|
&std::env::current_exe().context("Failed to resolve ECC executable path")?,
|
||||||
|
profile_name,
|
||||||
grouping,
|
grouping,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@ -228,6 +304,7 @@ pub async fn drain_inbox(
|
|||||||
use_worktree,
|
use_worktree,
|
||||||
&repo_root,
|
&repo_root,
|
||||||
&runner_program,
|
&runner_program,
|
||||||
|
None,
|
||||||
SessionGrouping::default(),
|
SessionGrouping::default(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@ -434,6 +511,7 @@ pub async fn rebalance_team_backlog(
|
|||||||
use_worktree,
|
use_worktree,
|
||||||
&repo_root,
|
&repo_root,
|
||||||
&runner_program,
|
&runner_program,
|
||||||
|
None,
|
||||||
SessionGrouping::default(),
|
SessionGrouping::default(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@ -464,12 +542,15 @@ pub async fn stop_session(db: &StateStore, id: &str) -> Result<()> {
|
|||||||
pub struct BudgetEnforcementOutcome {
|
pub struct BudgetEnforcementOutcome {
|
||||||
pub token_budget_exceeded: bool,
|
pub token_budget_exceeded: bool,
|
||||||
pub cost_budget_exceeded: bool,
|
pub cost_budget_exceeded: bool,
|
||||||
|
pub profile_token_budget_exceeded: bool,
|
||||||
pub paused_sessions: Vec<String>,
|
pub paused_sessions: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BudgetEnforcementOutcome {
|
impl BudgetEnforcementOutcome {
|
||||||
pub fn hard_limit_exceeded(&self) -> bool {
|
pub fn hard_limit_exceeded(&self) -> bool {
|
||||||
self.token_budget_exceeded || self.cost_budget_exceeded
|
self.token_budget_exceeded
|
||||||
|
|| self.cost_budget_exceeded
|
||||||
|
|| self.profile_token_budget_exceeded
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -490,18 +571,51 @@ pub fn enforce_budget_hard_limits(
|
|||||||
let mut outcome = BudgetEnforcementOutcome {
|
let mut outcome = BudgetEnforcementOutcome {
|
||||||
token_budget_exceeded: cfg.token_budget > 0 && total_tokens >= cfg.token_budget,
|
token_budget_exceeded: cfg.token_budget > 0 && total_tokens >= cfg.token_budget,
|
||||||
cost_budget_exceeded: cfg.cost_budget_usd > 0.0 && total_cost >= cfg.cost_budget_usd,
|
cost_budget_exceeded: cfg.cost_budget_usd > 0.0 && total_cost >= cfg.cost_budget_usd,
|
||||||
|
profile_token_budget_exceeded: false,
|
||||||
paused_sessions: Vec::new(),
|
paused_sessions: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mut sessions_to_pause = HashSet::new();
|
||||||
|
|
||||||
|
if outcome.token_budget_exceeded || outcome.cost_budget_exceeded {
|
||||||
|
for session in sessions.iter().filter(|session| {
|
||||||
|
matches!(
|
||||||
|
session.state,
|
||||||
|
SessionState::Pending | SessionState::Running | SessionState::Idle
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
sessions_to_pause.insert(session.id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for session in sessions.iter().filter(|session| {
|
||||||
|
matches!(
|
||||||
|
session.state,
|
||||||
|
SessionState::Pending | SessionState::Running | SessionState::Idle
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
let Some(profile) = db.get_session_profile(&session.id)? else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(token_budget) = profile.token_budget else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if token_budget > 0 && session.metrics.tokens_used >= token_budget {
|
||||||
|
outcome.profile_token_budget_exceeded = true;
|
||||||
|
sessions_to_pause.insert(session.id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !outcome.hard_limit_exceeded() {
|
if !outcome.hard_limit_exceeded() {
|
||||||
return Ok(outcome);
|
return Ok(outcome);
|
||||||
}
|
}
|
||||||
|
|
||||||
for session in sessions.into_iter().filter(|session| {
|
for session in sessions.into_iter().filter(|session| {
|
||||||
matches!(
|
sessions_to_pause.contains(&session.id)
|
||||||
session.state,
|
&& matches!(
|
||||||
SessionState::Pending | SessionState::Running | SessionState::Idle
|
session.state,
|
||||||
)
|
SessionState::Pending | SessionState::Running | SessionState::Idle
|
||||||
|
)
|
||||||
}) {
|
}) {
|
||||||
stop_session_recorded(db, &session, false)?;
|
stop_session_recorded(db, &session, false)?;
|
||||||
outcome.paused_sessions.push(session.id);
|
outcome.paused_sessions.push(session.id);
|
||||||
@ -820,6 +934,7 @@ async fn assign_session_in_dir_with_runner_program(
|
|||||||
use_worktree: bool,
|
use_worktree: bool,
|
||||||
repo_root: &Path,
|
repo_root: &Path,
|
||||||
runner_program: &Path,
|
runner_program: &Path,
|
||||||
|
profile_name: Option<&str>,
|
||||||
grouping: SessionGrouping,
|
grouping: SessionGrouping,
|
||||||
) -> Result<AssignmentOutcome> {
|
) -> Result<AssignmentOutcome> {
|
||||||
let lead = resolve_session(db, lead_id)?;
|
let lead = resolve_session(db, lead_id)?;
|
||||||
@ -868,6 +983,8 @@ async fn assign_session_in_dir_with_runner_program(
|
|||||||
use_worktree,
|
use_worktree,
|
||||||
repo_root,
|
repo_root,
|
||||||
runner_program,
|
runner_program,
|
||||||
|
profile_name,
|
||||||
|
Some(&lead.id),
|
||||||
inherited_grouping.clone(),
|
inherited_grouping.clone(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@ -943,6 +1060,8 @@ async fn assign_session_in_dir_with_runner_program(
|
|||||||
use_worktree,
|
use_worktree,
|
||||||
repo_root,
|
repo_root,
|
||||||
runner_program,
|
runner_program,
|
||||||
|
profile_name,
|
||||||
|
Some(&lead.id),
|
||||||
inherited_grouping,
|
inherited_grouping,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@ -1623,7 +1742,8 @@ pub async fn run_session(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let agent_program = agent_program(agent_type)?;
|
let agent_program = agent_program(agent_type)?;
|
||||||
let command = build_agent_command(&agent_program, task, session_id, working_dir);
|
let profile = db.get_session_profile(session_id)?;
|
||||||
|
let command = build_agent_command(&agent_program, task, session_id, working_dir, profile.as_ref());
|
||||||
capture_command_output(
|
capture_command_output(
|
||||||
cfg.db_path.clone(),
|
cfg.db_path.clone(),
|
||||||
session_id.to_string(),
|
session_id.to_string(),
|
||||||
@ -1750,6 +1870,8 @@ async fn queue_session_in_dir(
|
|||||||
agent_type: &str,
|
agent_type: &str,
|
||||||
use_worktree: bool,
|
use_worktree: bool,
|
||||||
repo_root: &Path,
|
repo_root: &Path,
|
||||||
|
profile_name: Option<&str>,
|
||||||
|
inherited_profile_session_id: Option<&str>,
|
||||||
grouping: SessionGrouping,
|
grouping: SessionGrouping,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
queue_session_in_dir_with_runner_program(
|
queue_session_in_dir_with_runner_program(
|
||||||
@ -1760,6 +1882,8 @@ async fn queue_session_in_dir(
|
|||||||
use_worktree,
|
use_worktree,
|
||||||
repo_root,
|
repo_root,
|
||||||
&std::env::current_exe().context("Failed to resolve ECC executable path")?,
|
&std::env::current_exe().context("Failed to resolve ECC executable path")?,
|
||||||
|
profile_name,
|
||||||
|
inherited_profile_session_id,
|
||||||
grouping,
|
grouping,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@ -1773,11 +1897,29 @@ async fn queue_session_in_dir_with_runner_program(
|
|||||||
use_worktree: bool,
|
use_worktree: bool,
|
||||||
repo_root: &Path,
|
repo_root: &Path,
|
||||||
runner_program: &Path,
|
runner_program: &Path,
|
||||||
|
profile_name: Option<&str>,
|
||||||
|
inherited_profile_session_id: Option<&str>,
|
||||||
grouping: SessionGrouping,
|
grouping: SessionGrouping,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let session =
|
let profile =
|
||||||
build_session_record(db, task, agent_type, use_worktree, cfg, repo_root, grouping)?;
|
resolve_launch_profile(db, cfg, profile_name, inherited_profile_session_id)?;
|
||||||
|
let effective_agent_type = profile
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|profile| profile.agent.as_deref())
|
||||||
|
.unwrap_or(agent_type);
|
||||||
|
let session = build_session_record(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
effective_agent_type,
|
||||||
|
use_worktree,
|
||||||
|
cfg,
|
||||||
|
repo_root,
|
||||||
|
grouping,
|
||||||
|
)?;
|
||||||
db.insert_session(&session)?;
|
db.insert_session(&session)?;
|
||||||
|
if let Some(profile) = profile.as_ref() {
|
||||||
|
db.upsert_session_profile(&session.id, profile)?;
|
||||||
|
}
|
||||||
|
|
||||||
if use_worktree && session.worktree.is_none() {
|
if use_worktree && session.worktree.is_none() {
|
||||||
db.enqueue_pending_worktree(&session.id, repo_root)?;
|
db.enqueue_pending_worktree(&session.id, repo_root)?;
|
||||||
@ -1793,7 +1935,7 @@ async fn queue_session_in_dir_with_runner_program(
|
|||||||
match spawn_session_runner_for_program(
|
match spawn_session_runner_for_program(
|
||||||
task,
|
task,
|
||||||
&session.id,
|
&session.id,
|
||||||
agent_type,
|
&session.agent_type,
|
||||||
working_dir,
|
working_dir,
|
||||||
runner_program,
|
runner_program,
|
||||||
)
|
)
|
||||||
@ -1911,6 +2053,27 @@ async fn create_session_in_dir(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_launch_profile(
|
||||||
|
db: &StateStore,
|
||||||
|
cfg: &Config,
|
||||||
|
explicit_profile_name: Option<&str>,
|
||||||
|
inherited_profile_session_id: Option<&str>,
|
||||||
|
) -> Result<Option<SessionAgentProfile>> {
|
||||||
|
let inherited_profile_name = match inherited_profile_session_id {
|
||||||
|
Some(session_id) => db.get_session_profile(session_id)?.map(|profile| profile.profile_name),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let profile_name = explicit_profile_name
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.or(inherited_profile_name)
|
||||||
|
.or_else(|| cfg.default_agent_profile.clone());
|
||||||
|
|
||||||
|
profile_name
|
||||||
|
.as_deref()
|
||||||
|
.map(|name| cfg.resolve_agent_profile(name))
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
|
||||||
fn attached_worktree_count(db: &StateStore) -> Result<usize> {
|
fn attached_worktree_count(db: &StateStore) -> Result<usize> {
|
||||||
Ok(db
|
Ok(db
|
||||||
.list_sessions()?
|
.list_sessions()?
|
||||||
@ -2075,16 +2238,44 @@ fn build_agent_command(
|
|||||||
task: &str,
|
task: &str,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
working_dir: &Path,
|
working_dir: &Path,
|
||||||
|
profile: Option<&SessionAgentProfile>,
|
||||||
) -> Command {
|
) -> Command {
|
||||||
let mut command = Command::new(agent_program);
|
let mut command = Command::new(agent_program);
|
||||||
|
command.env("ECC_SESSION_ID", session_id);
|
||||||
command
|
command
|
||||||
.env("ECC_SESSION_ID", session_id)
|
|
||||||
.arg("--print")
|
.arg("--print")
|
||||||
.arg("--name")
|
.arg("--name")
|
||||||
.arg(format!("ecc-{session_id}"))
|
.arg(format!("ecc-{session_id}"));
|
||||||
.arg(task)
|
if let Some(profile) = profile {
|
||||||
.current_dir(working_dir)
|
if let Some(model) = profile.model.as_ref() {
|
||||||
.stdin(Stdio::null());
|
command.arg("--model").arg(model);
|
||||||
|
}
|
||||||
|
if !profile.allowed_tools.is_empty() {
|
||||||
|
command
|
||||||
|
.arg("--allowed-tools")
|
||||||
|
.arg(profile.allowed_tools.join(","));
|
||||||
|
}
|
||||||
|
if !profile.disallowed_tools.is_empty() {
|
||||||
|
command
|
||||||
|
.arg("--disallowed-tools")
|
||||||
|
.arg(profile.disallowed_tools.join(","));
|
||||||
|
}
|
||||||
|
if let Some(permission_mode) = profile.permission_mode.as_ref() {
|
||||||
|
command.arg("--permission-mode").arg(permission_mode);
|
||||||
|
}
|
||||||
|
for dir in &profile.add_dirs {
|
||||||
|
command.arg("--add-dir").arg(dir);
|
||||||
|
}
|
||||||
|
if let Some(max_budget_usd) = profile.max_budget_usd {
|
||||||
|
command
|
||||||
|
.arg("--max-budget-usd")
|
||||||
|
.arg(max_budget_usd.to_string());
|
||||||
|
}
|
||||||
|
if let Some(prompt) = profile.append_system_prompt.as_ref() {
|
||||||
|
command.arg("--append-system-prompt").arg(prompt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
command.arg(task).current_dir(working_dir).stdin(Stdio::null());
|
||||||
command
|
command
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2094,7 +2285,7 @@ async fn spawn_claude_code(
|
|||||||
session_id: &str,
|
session_id: &str,
|
||||||
working_dir: &Path,
|
working_dir: &Path,
|
||||||
) -> Result<u32> {
|
) -> Result<u32> {
|
||||||
let mut command = build_agent_command(agent_program, task, session_id, working_dir);
|
let mut command = build_agent_command(agent_program, task, session_id, working_dir, None);
|
||||||
let child = command
|
let child = command
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::null())
|
.stderr(Stdio::null())
|
||||||
@ -2194,6 +2385,7 @@ async fn kill_process(pid: u32) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct SessionStatus {
|
pub struct SessionStatus {
|
||||||
|
profile: Option<SessionAgentProfile>,
|
||||||
session: Session,
|
session: Session,
|
||||||
parent_session: Option<String>,
|
parent_session: Option<String>,
|
||||||
delegated_children: Vec<String>,
|
delegated_children: Vec<String>,
|
||||||
@ -2363,6 +2555,21 @@ impl fmt::Display for SessionStatus {
|
|||||||
writeln!(f, "Task: {}", s.task)?;
|
writeln!(f, "Task: {}", s.task)?;
|
||||||
writeln!(f, "Agent: {}", s.agent_type)?;
|
writeln!(f, "Agent: {}", s.agent_type)?;
|
||||||
writeln!(f, "State: {}", s.state)?;
|
writeln!(f, "State: {}", s.state)?;
|
||||||
|
if let Some(profile) = self.profile.as_ref() {
|
||||||
|
writeln!(f, "Profile: {}", profile.profile_name)?;
|
||||||
|
if let Some(model) = profile.model.as_ref() {
|
||||||
|
writeln!(f, "Model: {}", model)?;
|
||||||
|
}
|
||||||
|
if let Some(permission_mode) = profile.permission_mode.as_ref() {
|
||||||
|
writeln!(f, "Perms: {}", permission_mode)?;
|
||||||
|
}
|
||||||
|
if let Some(token_budget) = profile.token_budget {
|
||||||
|
writeln!(f, "Profile tokens: {}", token_budget)?;
|
||||||
|
}
|
||||||
|
if let Some(max_budget_usd) = profile.max_budget_usd {
|
||||||
|
writeln!(f, "Profile cost: ${max_budget_usd:.4}")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(parent) = self.parent_session.as_ref() {
|
if let Some(parent) = self.parent_session.as_ref() {
|
||||||
writeln!(f, "Parent: {}", parent)?;
|
writeln!(f, "Parent: {}", parent)?;
|
||||||
}
|
}
|
||||||
@ -2590,7 +2797,7 @@ fn session_state_label(state: &SessionState) -> &'static str {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::{Config, PaneLayout, Theme};
|
use crate::config::{Config, PaneLayout, Theme};
|
||||||
use crate::session::{Session, SessionMetrics, SessionState};
|
use crate::session::{Session, SessionAgentProfile, SessionMetrics, SessionState};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@ -2635,6 +2842,8 @@ mod tests {
|
|||||||
heartbeat_interval_secs: 5,
|
heartbeat_interval_secs: 5,
|
||||||
auto_terminate_stale_sessions: false,
|
auto_terminate_stale_sessions: false,
|
||||||
default_agent: "claude".to_string(),
|
default_agent: "claude".to_string(),
|
||||||
|
default_agent_profile: None,
|
||||||
|
agent_profiles: Default::default(),
|
||||||
auto_dispatch_unread_handoffs: false,
|
auto_dispatch_unread_handoffs: false,
|
||||||
auto_dispatch_limit_per_session: 5,
|
auto_dispatch_limit_per_session: 5,
|
||||||
auto_create_worktrees: true,
|
auto_create_worktrees: true,
|
||||||
@ -2674,6 +2883,61 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_agent_command_applies_profile_runner_flags() {
|
||||||
|
let profile = SessionAgentProfile {
|
||||||
|
profile_name: "reviewer".to_string(),
|
||||||
|
agent: None,
|
||||||
|
model: Some("sonnet".to_string()),
|
||||||
|
allowed_tools: vec!["Read".to_string(), "Edit".to_string()],
|
||||||
|
disallowed_tools: vec!["Bash".to_string()],
|
||||||
|
permission_mode: Some("plan".to_string()),
|
||||||
|
add_dirs: vec![PathBuf::from("docs"), PathBuf::from("specs")],
|
||||||
|
max_budget_usd: Some(1.25),
|
||||||
|
token_budget: Some(750),
|
||||||
|
append_system_prompt: Some("Review thoroughly.".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = build_agent_command(
|
||||||
|
Path::new("claude"),
|
||||||
|
"review this change",
|
||||||
|
"sess-1234",
|
||||||
|
Path::new("/tmp/repo"),
|
||||||
|
Some(&profile),
|
||||||
|
);
|
||||||
|
let args = command
|
||||||
|
.as_std()
|
||||||
|
.get_args()
|
||||||
|
.map(|value| value.to_string_lossy().to_string())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
args,
|
||||||
|
vec![
|
||||||
|
"--print",
|
||||||
|
"--name",
|
||||||
|
"ecc-sess-1234",
|
||||||
|
"--model",
|
||||||
|
"sonnet",
|
||||||
|
"--allowed-tools",
|
||||||
|
"Read,Edit",
|
||||||
|
"--disallowed-tools",
|
||||||
|
"Bash",
|
||||||
|
"--permission-mode",
|
||||||
|
"plan",
|
||||||
|
"--add-dir",
|
||||||
|
"docs",
|
||||||
|
"--add-dir",
|
||||||
|
"specs",
|
||||||
|
"--max-budget-usd",
|
||||||
|
"1.25",
|
||||||
|
"--append-system-prompt",
|
||||||
|
"Review thoroughly.",
|
||||||
|
"review this change",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn enforce_session_heartbeats_marks_overdue_running_sessions_stale() -> Result<()> {
|
fn enforce_session_heartbeats_marks_overdue_running_sessions_stale() -> Result<()> {
|
||||||
let tempdir = TestDir::new("manager-heartbeat-stale")?;
|
let tempdir = TestDir::new("manager-heartbeat-stale")?;
|
||||||
@ -3099,6 +3363,62 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn create_session_uses_default_agent_profile_and_persists_launch_settings() -> Result<()> {
|
||||||
|
let tempdir = TestDir::new("manager-default-agent-profile")?;
|
||||||
|
let repo_root = tempdir.path().join("repo");
|
||||||
|
init_git_repo(&repo_root)?;
|
||||||
|
|
||||||
|
let mut cfg = build_config(tempdir.path());
|
||||||
|
cfg.default_agent_profile = Some("reviewer".to_string());
|
||||||
|
cfg.agent_profiles.insert(
|
||||||
|
"reviewer".to_string(),
|
||||||
|
crate::config::AgentProfileConfig {
|
||||||
|
model: Some("sonnet".to_string()),
|
||||||
|
allowed_tools: vec!["Read".to_string(), "Edit".to_string()],
|
||||||
|
disallowed_tools: vec!["Bash".to_string()],
|
||||||
|
permission_mode: Some("plan".to_string()),
|
||||||
|
add_dirs: vec![PathBuf::from("docs")],
|
||||||
|
token_budget: Some(800),
|
||||||
|
append_system_prompt: Some("Review thoroughly.".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let db = StateStore::open(&cfg.db_path)?;
|
||||||
|
let (fake_runner, _) = write_fake_claude(tempdir.path())?;
|
||||||
|
|
||||||
|
let session_id = queue_session_in_dir_with_runner_program(
|
||||||
|
&db,
|
||||||
|
&cfg,
|
||||||
|
"review work",
|
||||||
|
"claude",
|
||||||
|
false,
|
||||||
|
&repo_root,
|
||||||
|
&fake_runner,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
SessionGrouping::default(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let profile = db
|
||||||
|
.get_session_profile(&session_id)?
|
||||||
|
.context("session profile should be persisted")?;
|
||||||
|
assert_eq!(profile.profile_name, "reviewer");
|
||||||
|
assert_eq!(profile.model.as_deref(), Some("sonnet"));
|
||||||
|
assert_eq!(profile.allowed_tools, vec!["Read", "Edit"]);
|
||||||
|
assert_eq!(profile.disallowed_tools, vec!["Bash"]);
|
||||||
|
assert_eq!(profile.permission_mode.as_deref(), Some("plan"));
|
||||||
|
assert_eq!(profile.add_dirs, vec![PathBuf::from("docs")]);
|
||||||
|
assert_eq!(profile.token_budget, Some(800));
|
||||||
|
assert_eq!(
|
||||||
|
profile.append_system_prompt.as_deref(),
|
||||||
|
Some("Review thoroughly.")
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn enforce_budget_hard_limits_stops_active_sessions_without_cleaning_worktrees() -> Result<()> {
|
fn enforce_budget_hard_limits_stops_active_sessions_without_cleaning_worktrees() -> Result<()> {
|
||||||
let tempdir = TestDir::new("manager-budget-pause")?;
|
let tempdir = TestDir::new("manager-budget-pause")?;
|
||||||
@ -3214,6 +3534,73 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enforce_budget_hard_limits_pauses_sessions_over_profile_token_budget() -> Result<()> {
|
||||||
|
let tempdir = TestDir::new("manager-profile-token-budget")?;
|
||||||
|
let cfg = build_config(tempdir.path());
|
||||||
|
let db = StateStore::open(&cfg.db_path)?;
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
db.insert_session(&Session {
|
||||||
|
id: "profile-over-budget".to_string(),
|
||||||
|
task: "review work".to_string(),
|
||||||
|
project: "workspace".to_string(),
|
||||||
|
task_group: "general".to_string(),
|
||||||
|
agent_type: "claude".to_string(),
|
||||||
|
working_dir: tempdir.path().to_path_buf(),
|
||||||
|
state: SessionState::Running,
|
||||||
|
pid: Some(999_998),
|
||||||
|
worktree: None,
|
||||||
|
created_at: now - Duration::minutes(1),
|
||||||
|
updated_at: now,
|
||||||
|
last_heartbeat_at: now,
|
||||||
|
metrics: SessionMetrics::default(),
|
||||||
|
})?;
|
||||||
|
db.upsert_session_profile(
|
||||||
|
"profile-over-budget",
|
||||||
|
&SessionAgentProfile {
|
||||||
|
profile_name: "reviewer".to_string(),
|
||||||
|
agent: None,
|
||||||
|
model: Some("sonnet".to_string()),
|
||||||
|
allowed_tools: vec!["Read".to_string()],
|
||||||
|
disallowed_tools: Vec::new(),
|
||||||
|
permission_mode: Some("plan".to_string()),
|
||||||
|
add_dirs: Vec::new(),
|
||||||
|
max_budget_usd: None,
|
||||||
|
token_budget: Some(75),
|
||||||
|
append_system_prompt: None,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
db.update_metrics(
|
||||||
|
"profile-over-budget",
|
||||||
|
&SessionMetrics {
|
||||||
|
input_tokens: 60,
|
||||||
|
output_tokens: 30,
|
||||||
|
tokens_used: 90,
|
||||||
|
tool_calls: 0,
|
||||||
|
files_changed: 0,
|
||||||
|
duration_secs: 60,
|
||||||
|
cost_usd: 0.0,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let outcome = enforce_budget_hard_limits(&db, &cfg)?;
|
||||||
|
assert!(!outcome.token_budget_exceeded);
|
||||||
|
assert!(!outcome.cost_budget_exceeded);
|
||||||
|
assert!(outcome.profile_token_budget_exceeded);
|
||||||
|
assert_eq!(
|
||||||
|
outcome.paused_sessions,
|
||||||
|
vec!["profile-over-budget".to_string()]
|
||||||
|
);
|
||||||
|
|
||||||
|
let session = db
|
||||||
|
.get_session("profile-over-budget")?
|
||||||
|
.context("session should still exist")?;
|
||||||
|
assert_eq!(session.state, SessionState::Stopped);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
async fn resume_session_requeues_failed_session() -> Result<()> {
|
async fn resume_session_requeues_failed_session() -> Result<()> {
|
||||||
let tempdir = TestDir::new("manager-resume-session")?;
|
let tempdir = TestDir::new("manager-resume-session")?;
|
||||||
@ -4108,6 +4495,7 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
&repo_root,
|
&repo_root,
|
||||||
&fake_runner,
|
&fake_runner,
|
||||||
|
None,
|
||||||
SessionGrouping::default(),
|
SessionGrouping::default(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@ -4181,6 +4569,7 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
&repo_root,
|
&repo_root,
|
||||||
&fake_runner,
|
&fake_runner,
|
||||||
|
None,
|
||||||
SessionGrouping::default(),
|
SessionGrouping::default(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@ -4266,6 +4655,7 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
&repo_root,
|
&repo_root,
|
||||||
&fake_runner,
|
&fake_runner,
|
||||||
|
None,
|
||||||
SessionGrouping::default(),
|
SessionGrouping::default(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@ -4338,6 +4728,7 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
&repo_root,
|
&repo_root,
|
||||||
&fake_runner,
|
&fake_runner,
|
||||||
|
None,
|
||||||
SessionGrouping::default(),
|
SessionGrouping::default(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@ -4394,6 +4785,7 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
&repo_root,
|
&repo_root,
|
||||||
&fake_runner,
|
&fake_runner,
|
||||||
|
None,
|
||||||
SessionGrouping::default(),
|
SessionGrouping::default(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@ -4467,6 +4859,7 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
&repo_root,
|
&repo_root,
|
||||||
&fake_runner,
|
&fake_runner,
|
||||||
|
None,
|
||||||
SessionGrouping::default(),
|
SessionGrouping::default(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@ -10,6 +10,8 @@ use std::fmt;
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
pub type SessionAgentProfile = crate::config::ResolvedAgentProfile;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|||||||
@ -14,8 +14,8 @@ use crate::observability::{ToolCallEvent, ToolLogEntry, ToolLogPage};
|
|||||||
use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT};
|
use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT};
|
||||||
use super::{
|
use super::{
|
||||||
default_project_label, default_task_group_label, normalize_group_label, DecisionLogEntry,
|
default_project_label, default_task_group_label, normalize_group_label, DecisionLogEntry,
|
||||||
FileActivityAction, FileActivityEntry, Session, SessionMessage, SessionMetrics, SessionState,
|
FileActivityAction, FileActivityEntry, Session, SessionAgentProfile, SessionMessage,
|
||||||
WorktreeInfo,
|
SessionMetrics, SessionState, WorktreeInfo,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct StateStore {
|
pub struct StateStore {
|
||||||
@ -194,6 +194,19 @@ impl StateStore {
|
|||||||
file_events_json TEXT NOT NULL DEFAULT '[]'
|
file_events_json TEXT NOT NULL DEFAULT '[]'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS session_profiles (
|
||||||
|
session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
profile_name TEXT NOT NULL,
|
||||||
|
model TEXT,
|
||||||
|
allowed_tools_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
disallowed_tools_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
permission_mode TEXT,
|
||||||
|
add_dirs_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
max_budget_usd REAL,
|
||||||
|
token_budget INTEGER,
|
||||||
|
append_system_prompt TEXT
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
from_session TEXT NOT NULL,
|
from_session TEXT NOT NULL,
|
||||||
@ -569,6 +582,98 @@ impl StateStore {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn upsert_session_profile(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
profile: &SessionAgentProfile,
|
||||||
|
) -> Result<()> {
|
||||||
|
let allowed_tools_json = serde_json::to_string(&profile.allowed_tools)
|
||||||
|
.context("serialize allowed agent profile tools")?;
|
||||||
|
let disallowed_tools_json = serde_json::to_string(&profile.disallowed_tools)
|
||||||
|
.context("serialize disallowed agent profile tools")?;
|
||||||
|
let add_dirs_json = serde_json::to_string(&profile.add_dirs)
|
||||||
|
.context("serialize agent profile add_dirs")?;
|
||||||
|
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO session_profiles (
|
||||||
|
session_id,
|
||||||
|
profile_name,
|
||||||
|
model,
|
||||||
|
allowed_tools_json,
|
||||||
|
disallowed_tools_json,
|
||||||
|
permission_mode,
|
||||||
|
add_dirs_json,
|
||||||
|
max_budget_usd,
|
||||||
|
token_budget,
|
||||||
|
append_system_prompt
|
||||||
|
)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
|
||||||
|
ON CONFLICT(session_id) DO UPDATE SET
|
||||||
|
profile_name = excluded.profile_name,
|
||||||
|
model = excluded.model,
|
||||||
|
allowed_tools_json = excluded.allowed_tools_json,
|
||||||
|
disallowed_tools_json = excluded.disallowed_tools_json,
|
||||||
|
permission_mode = excluded.permission_mode,
|
||||||
|
add_dirs_json = excluded.add_dirs_json,
|
||||||
|
max_budget_usd = excluded.max_budget_usd,
|
||||||
|
token_budget = excluded.token_budget,
|
||||||
|
append_system_prompt = excluded.append_system_prompt",
|
||||||
|
rusqlite::params![
|
||||||
|
session_id,
|
||||||
|
profile.profile_name,
|
||||||
|
profile.model,
|
||||||
|
allowed_tools_json,
|
||||||
|
disallowed_tools_json,
|
||||||
|
profile.permission_mode,
|
||||||
|
add_dirs_json,
|
||||||
|
profile.max_budget_usd,
|
||||||
|
profile.token_budget,
|
||||||
|
profile.append_system_prompt,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_session_profile(&self, session_id: &str) -> Result<Option<SessionAgentProfile>> {
|
||||||
|
self.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT
|
||||||
|
profile_name,
|
||||||
|
model,
|
||||||
|
allowed_tools_json,
|
||||||
|
disallowed_tools_json,
|
||||||
|
permission_mode,
|
||||||
|
add_dirs_json,
|
||||||
|
max_budget_usd,
|
||||||
|
token_budget,
|
||||||
|
append_system_prompt
|
||||||
|
FROM session_profiles
|
||||||
|
WHERE session_id = ?1",
|
||||||
|
[session_id],
|
||||||
|
|row| {
|
||||||
|
let allowed_tools_json: String = row.get(2)?;
|
||||||
|
let disallowed_tools_json: String = row.get(3)?;
|
||||||
|
let add_dirs_json: String = row.get(5)?;
|
||||||
|
Ok(SessionAgentProfile {
|
||||||
|
profile_name: row.get(0)?,
|
||||||
|
model: row.get(1)?,
|
||||||
|
allowed_tools: serde_json::from_str(&allowed_tools_json)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
disallowed_tools: serde_json::from_str(&disallowed_tools_json)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
permission_mode: row.get(4)?,
|
||||||
|
add_dirs: serde_json::from_str(&add_dirs_json).unwrap_or_default(),
|
||||||
|
max_budget_usd: row.get(6)?,
|
||||||
|
token_budget: row.get(7)?,
|
||||||
|
append_system_prompt: row.get(8)?,
|
||||||
|
agent: None,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn update_state_and_pid(
|
pub fn update_state_and_pid(
|
||||||
&self,
|
&self,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
@ -2532,6 +2637,63 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_profile_round_trips_with_launch_settings() -> Result<()> {
|
||||||
|
let tempdir = TestDir::new("store-session-profile")?;
|
||||||
|
let db = StateStore::open(&tempdir.path().join("state.db"))?;
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
db.insert_session(&Session {
|
||||||
|
id: "session-1".to_string(),
|
||||||
|
task: "review work".to_string(),
|
||||||
|
project: "workspace".to_string(),
|
||||||
|
task_group: "general".to_string(),
|
||||||
|
agent_type: "claude".to_string(),
|
||||||
|
working_dir: PathBuf::from("/tmp"),
|
||||||
|
state: SessionState::Pending,
|
||||||
|
pid: None,
|
||||||
|
worktree: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
last_heartbeat_at: now,
|
||||||
|
metrics: SessionMetrics::default(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
db.upsert_session_profile(
|
||||||
|
"session-1",
|
||||||
|
&crate::session::SessionAgentProfile {
|
||||||
|
agent: None,
|
||||||
|
profile_name: "reviewer".to_string(),
|
||||||
|
model: Some("sonnet".to_string()),
|
||||||
|
allowed_tools: vec!["Read".to_string(), "Edit".to_string()],
|
||||||
|
disallowed_tools: vec!["Bash".to_string()],
|
||||||
|
permission_mode: Some("plan".to_string()),
|
||||||
|
add_dirs: vec![PathBuf::from("docs"), PathBuf::from("specs")],
|
||||||
|
max_budget_usd: Some(1.5),
|
||||||
|
token_budget: Some(1200),
|
||||||
|
append_system_prompt: Some("Review thoroughly.".to_string()),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let profile = db
|
||||||
|
.get_session_profile("session-1")?
|
||||||
|
.expect("profile should be stored");
|
||||||
|
assert_eq!(profile.profile_name, "reviewer");
|
||||||
|
assert_eq!(profile.model.as_deref(), Some("sonnet"));
|
||||||
|
assert_eq!(profile.allowed_tools, vec!["Read", "Edit"]);
|
||||||
|
assert_eq!(profile.disallowed_tools, vec!["Bash"]);
|
||||||
|
assert_eq!(profile.permission_mode.as_deref(), Some("plan"));
|
||||||
|
assert_eq!(profile.add_dirs, vec![PathBuf::from("docs"), PathBuf::from("specs")]);
|
||||||
|
assert_eq!(profile.max_budget_usd, Some(1.5));
|
||||||
|
assert_eq!(profile.token_budget, Some(1200));
|
||||||
|
assert_eq!(
|
||||||
|
profile.append_system_prompt.as_deref(),
|
||||||
|
Some("Review thoroughly.")
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sync_cost_tracker_metrics_aggregates_usage_into_sessions() -> Result<()> {
|
fn sync_cost_tracker_metrics_aggregates_usage_into_sessions() -> Result<()> {
|
||||||
let tempdir = TestDir::new("store-cost-metrics")?;
|
let tempdir = TestDir::new("store-cost-metrics")?;
|
||||||
|
|||||||
@ -5392,6 +5392,11 @@ impl Dashboard {
|
|||||||
fn selected_session_metrics_text(&self) -> String {
|
fn selected_session_metrics_text(&self) -> String {
|
||||||
if let Some(session) = self.sessions.get(self.selected_session) {
|
if let Some(session) = self.sessions.get(self.selected_session) {
|
||||||
let metrics = &session.metrics;
|
let metrics = &session.metrics;
|
||||||
|
let selected_profile = self
|
||||||
|
.db
|
||||||
|
.get_session_profile(&session.id)
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
let group_peers = self
|
let group_peers = self
|
||||||
.sessions
|
.sessions
|
||||||
.iter()
|
.iter()
|
||||||
@ -5413,6 +5418,57 @@ impl Dashboard {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if let Some(profile) = selected_profile.as_ref() {
|
||||||
|
let model = profile.model.as_deref().unwrap_or("default");
|
||||||
|
let permission_mode = profile.permission_mode.as_deref().unwrap_or("default");
|
||||||
|
lines.push(format!(
|
||||||
|
"Profile {} | Model {} | Permissions {}",
|
||||||
|
profile.profile_name, model, permission_mode
|
||||||
|
));
|
||||||
|
let mut profile_details = Vec::new();
|
||||||
|
if let Some(token_budget) = profile.token_budget {
|
||||||
|
profile_details.push(format!(
|
||||||
|
"Profile tokens {}",
|
||||||
|
format_token_count(token_budget)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(max_budget_usd) = profile.max_budget_usd {
|
||||||
|
profile_details.push(format!(
|
||||||
|
"Profile cost {}",
|
||||||
|
format_currency(max_budget_usd)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !profile.allowed_tools.is_empty() {
|
||||||
|
profile_details.push(format!(
|
||||||
|
"Allow {}",
|
||||||
|
truncate_for_dashboard(&profile.allowed_tools.join(", "), 36)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !profile.disallowed_tools.is_empty() {
|
||||||
|
profile_details.push(format!(
|
||||||
|
"Deny {}",
|
||||||
|
truncate_for_dashboard(&profile.disallowed_tools.join(", "), 36)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !profile.add_dirs.is_empty() {
|
||||||
|
profile_details.push(format!(
|
||||||
|
"Dirs {}",
|
||||||
|
truncate_for_dashboard(
|
||||||
|
&profile
|
||||||
|
.add_dirs
|
||||||
|
.iter()
|
||||||
|
.map(|path| path.display().to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", "),
|
||||||
|
36
|
||||||
|
)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !profile_details.is_empty() {
|
||||||
|
lines.push(profile_details.join(" | "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(parent) = self.selected_parent_session.as_ref() {
|
if let Some(parent) = self.selected_parent_session.as_ref() {
|
||||||
lines.push(format!("Delegated from {}", format_session_id(parent)));
|
lines.push(format!("Delegated from {}", format_session_id(parent)));
|
||||||
}
|
}
|
||||||
@ -7878,11 +7934,16 @@ fn heartbeat_enforcement_note(outcome: &manager::HeartbeatEnforcementOutcome) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn budget_auto_pause_note(outcome: &manager::BudgetEnforcementOutcome) -> String {
|
fn budget_auto_pause_note(outcome: &manager::BudgetEnforcementOutcome) -> String {
|
||||||
let cause = match (outcome.token_budget_exceeded, outcome.cost_budget_exceeded) {
|
let cause = match (
|
||||||
(true, true) => "token and cost budgets exceeded",
|
outcome.token_budget_exceeded,
|
||||||
(true, false) => "token budget exceeded",
|
outcome.cost_budget_exceeded,
|
||||||
(false, true) => "cost budget exceeded",
|
outcome.profile_token_budget_exceeded,
|
||||||
(false, false) => "budget exceeded",
|
) {
|
||||||
|
(true, true, _) => "token and cost budgets exceeded",
|
||||||
|
(true, false, _) => "token budget exceeded",
|
||||||
|
(false, true, _) => "cost budget exceeded",
|
||||||
|
(false, false, true) => "profile token budget exceeded",
|
||||||
|
(false, false, false) => "budget exceeded",
|
||||||
};
|
};
|
||||||
|
|
||||||
format!(
|
format!(
|
||||||
@ -13011,6 +13072,8 @@ diff --git a/src/lib.rs b/src/lib.rs
|
|||||||
heartbeat_interval_secs: 5,
|
heartbeat_interval_secs: 5,
|
||||||
auto_terminate_stale_sessions: false,
|
auto_terminate_stale_sessions: false,
|
||||||
default_agent: "claude".to_string(),
|
default_agent: "claude".to_string(),
|
||||||
|
default_agent_profile: None,
|
||||||
|
agent_profiles: Default::default(),
|
||||||
auto_dispatch_unread_handoffs: false,
|
auto_dispatch_unread_handoffs: false,
|
||||||
auto_dispatch_limit_per_session: 5,
|
auto_dispatch_limit_per_session: 5,
|
||||||
auto_create_worktrees: true,
|
auto_create_worktrees: true,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user