mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-09 19:03:28 +08:00
feat: rebalance ecc2 delegate backlog
This commit is contained in:
@@ -90,6 +90,20 @@ enum Commands {
|
|||||||
#[arg(long, default_value_t = 10)]
|
#[arg(long, default_value_t = 10)]
|
||||||
lead_limit: usize,
|
lead_limit: usize,
|
||||||
},
|
},
|
||||||
|
/// Rebalance unread handoffs off backed-up delegates onto clearer team capacity
|
||||||
|
RebalanceTeam {
|
||||||
|
/// Lead session ID or alias
|
||||||
|
session_id: String,
|
||||||
|
/// Agent type for routed delegates
|
||||||
|
#[arg(short, long, default_value = "claude")]
|
||||||
|
agent: String,
|
||||||
|
/// Create a dedicated worktree if new delegates must be spawned
|
||||||
|
#[arg(short, long, default_value_t = true)]
|
||||||
|
worktree: bool,
|
||||||
|
/// Maximum handoffs to reroute in one pass
|
||||||
|
#[arg(long, default_value_t = 5)]
|
||||||
|
limit: usize,
|
||||||
|
},
|
||||||
/// List active sessions
|
/// List active sessions
|
||||||
Sessions,
|
Sessions,
|
||||||
/// Show session details
|
/// Show session details
|
||||||
@@ -323,6 +337,46 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some(Commands::RebalanceTeam {
|
||||||
|
session_id,
|
||||||
|
agent,
|
||||||
|
worktree: use_worktree,
|
||||||
|
limit,
|
||||||
|
}) => {
|
||||||
|
let lead_id = resolve_session_id(&db, &session_id)?;
|
||||||
|
let outcomes = session::manager::rebalance_team_backlog(
|
||||||
|
&db,
|
||||||
|
&cfg,
|
||||||
|
&lead_id,
|
||||||
|
&agent,
|
||||||
|
use_worktree,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if outcomes.is_empty() {
|
||||||
|
println!("No delegate backlog needed rebalancing for {}", short_session(&lead_id));
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"Rebalanced {} task handoff(s) for {}",
|
||||||
|
outcomes.len(),
|
||||||
|
short_session(&lead_id)
|
||||||
|
);
|
||||||
|
for outcome in outcomes {
|
||||||
|
println!(
|
||||||
|
"- {} | {} -> {} ({}) | {}",
|
||||||
|
outcome.message_id,
|
||||||
|
short_session(&outcome.from_session_id),
|
||||||
|
short_session(&outcome.session_id),
|
||||||
|
match outcome.action {
|
||||||
|
session::manager::AssignmentAction::Spawned => "spawned",
|
||||||
|
session::manager::AssignmentAction::ReusedIdle => "reused-idle",
|
||||||
|
session::manager::AssignmentAction::ReusedActive => "reused-active",
|
||||||
|
},
|
||||||
|
outcome.task
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some(Commands::Sessions) => {
|
Some(Commands::Sessions) => {
|
||||||
let sessions = session::manager::list_sessions(&db)?;
|
let sessions = session::manager::list_sessions(&db)?;
|
||||||
for s in sessions {
|
for s in sessions {
|
||||||
@@ -692,4 +746,32 @@ mod tests {
|
|||||||
_ => panic!("expected auto-dispatch subcommand"),
|
_ => panic!("expected auto-dispatch subcommand"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cli_parses_rebalance_team_command() {
|
||||||
|
let cli = Cli::try_parse_from([
|
||||||
|
"ecc",
|
||||||
|
"rebalance-team",
|
||||||
|
"lead",
|
||||||
|
"--agent",
|
||||||
|
"claude",
|
||||||
|
"--limit",
|
||||||
|
"2",
|
||||||
|
])
|
||||||
|
.expect("rebalance-team should parse");
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Some(Commands::RebalanceTeam {
|
||||||
|
session_id,
|
||||||
|
agent,
|
||||||
|
limit,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
assert_eq!(session_id, "lead");
|
||||||
|
assert_eq!(agent, "claude");
|
||||||
|
assert_eq!(limit, 2);
|
||||||
|
}
|
||||||
|
_ => panic!("expected rebalance-team subcommand"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,6 +166,113 @@ pub async fn auto_dispatch_backlog(
|
|||||||
Ok(outcomes)
|
Ok(outcomes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn rebalance_team_backlog(
|
||||||
|
db: &StateStore,
|
||||||
|
cfg: &Config,
|
||||||
|
lead_id: &str,
|
||||||
|
agent_type: &str,
|
||||||
|
use_worktree: bool,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<RebalanceOutcome>> {
|
||||||
|
let repo_root =
|
||||||
|
std::env::current_dir().context("Failed to resolve current working directory")?;
|
||||||
|
let runner_program = std::env::current_exe().context("Failed to resolve ECC executable path")?;
|
||||||
|
let lead = resolve_session(db, lead_id)?;
|
||||||
|
let mut outcomes = Vec::new();
|
||||||
|
|
||||||
|
if limit == 0 {
|
||||||
|
return Ok(outcomes);
|
||||||
|
}
|
||||||
|
|
||||||
|
let delegates = direct_delegate_sessions(db, &lead.id, agent_type)?;
|
||||||
|
let unread_counts = db.unread_message_counts()?;
|
||||||
|
let team_has_capacity = delegates.len() < cfg.max_parallel_sessions;
|
||||||
|
|
||||||
|
for delegate in &delegates {
|
||||||
|
if outcomes.len() >= limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let unread_count = unread_counts.get(&delegate.id).copied().unwrap_or(0);
|
||||||
|
if unread_count <= 1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let has_clear_idle_elsewhere = delegates.iter().any(|candidate| {
|
||||||
|
candidate.id != delegate.id
|
||||||
|
&& candidate.state == SessionState::Idle
|
||||||
|
&& unread_counts.get(&candidate.id).copied().unwrap_or(0) == 0
|
||||||
|
});
|
||||||
|
|
||||||
|
if !has_clear_idle_elsewhere && !team_has_capacity {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let message_budget = limit.saturating_sub(outcomes.len());
|
||||||
|
let messages = db.unread_task_handoffs_for_session(&delegate.id, message_budget)?;
|
||||||
|
|
||||||
|
for message in messages {
|
||||||
|
if outcomes.len() >= limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_delegates = direct_delegate_sessions(db, &lead.id, agent_type)?;
|
||||||
|
let current_unread_counts = db.unread_message_counts()?;
|
||||||
|
let current_team_has_capacity = current_delegates.len() < cfg.max_parallel_sessions;
|
||||||
|
let current_has_clear_idle_elsewhere = current_delegates.iter().any(|candidate| {
|
||||||
|
candidate.id != delegate.id
|
||||||
|
&& candidate.state == SessionState::Idle
|
||||||
|
&& current_unread_counts
|
||||||
|
.get(&candidate.id)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(0)
|
||||||
|
== 0
|
||||||
|
});
|
||||||
|
|
||||||
|
if !current_has_clear_idle_elsewhere && !current_team_has_capacity {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if message.from_session != lead.id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let task = match comms::parse(&message.content) {
|
||||||
|
Some(MessageType::TaskHandoff { task, .. }) => task,
|
||||||
|
_ => extract_legacy_handoff_task(&message.content)
|
||||||
|
.unwrap_or_else(|| message.content.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let outcome = assign_session_in_dir_with_runner_program(
|
||||||
|
db,
|
||||||
|
cfg,
|
||||||
|
&lead.id,
|
||||||
|
&task,
|
||||||
|
agent_type,
|
||||||
|
use_worktree,
|
||||||
|
&repo_root,
|
||||||
|
&runner_program,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if outcome.session_id == delegate.id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = db.mark_message_read(message.id)?;
|
||||||
|
outcomes.push(RebalanceOutcome {
|
||||||
|
from_session_id: delegate.id.clone(),
|
||||||
|
message_id: message.id,
|
||||||
|
task,
|
||||||
|
session_id: outcome.session_id,
|
||||||
|
action: outcome.action,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(outcomes)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn stop_session(db: &StateStore, id: &str) -> Result<()> {
|
pub async fn stop_session(db: &StateStore, id: &str) -> Result<()> {
|
||||||
stop_session_with_options(db, id, true).await
|
stop_session_with_options(db, id, true).await
|
||||||
}
|
}
|
||||||
@@ -850,6 +957,14 @@ pub struct LeadDispatchOutcome {
|
|||||||
pub routed: Vec<InboxDrainOutcome>,
|
pub routed: Vec<InboxDrainOutcome>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct RebalanceOutcome {
|
||||||
|
pub from_session_id: String,
|
||||||
|
pub message_id: i64,
|
||||||
|
pub task: String,
|
||||||
|
pub session_id: String,
|
||||||
|
pub action: AssignmentAction,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum AssignmentAction {
|
pub enum AssignmentAction {
|
||||||
Spawned,
|
Spawned,
|
||||||
@@ -1739,4 +1854,91 @@ mod tests {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn rebalance_team_backlog_moves_work_off_backed_up_delegate() -> Result<()> {
|
||||||
|
let tempdir = TestDir::new("manager-rebalance-team")?;
|
||||||
|
let repo_root = tempdir.path().join("repo");
|
||||||
|
init_git_repo(&repo_root)?;
|
||||||
|
|
||||||
|
let mut cfg = build_config(tempdir.path());
|
||||||
|
cfg.max_parallel_sessions = 2;
|
||||||
|
let db = StateStore::open(&cfg.db_path)?;
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
db.insert_session(&Session {
|
||||||
|
id: "lead".to_string(),
|
||||||
|
task: "lead task".to_string(),
|
||||||
|
agent_type: "claude".to_string(),
|
||||||
|
working_dir: repo_root.clone(),
|
||||||
|
state: SessionState::Running,
|
||||||
|
pid: Some(42),
|
||||||
|
worktree: None,
|
||||||
|
created_at: now - Duration::minutes(4),
|
||||||
|
updated_at: now - Duration::minutes(4),
|
||||||
|
metrics: SessionMetrics::default(),
|
||||||
|
})?;
|
||||||
|
db.insert_session(&Session {
|
||||||
|
id: "worker-a".to_string(),
|
||||||
|
task: "auth lane".to_string(),
|
||||||
|
agent_type: "claude".to_string(),
|
||||||
|
working_dir: repo_root.clone(),
|
||||||
|
state: SessionState::Idle,
|
||||||
|
pid: None,
|
||||||
|
worktree: None,
|
||||||
|
created_at: now - Duration::minutes(3),
|
||||||
|
updated_at: now - Duration::minutes(3),
|
||||||
|
metrics: SessionMetrics::default(),
|
||||||
|
})?;
|
||||||
|
db.insert_session(&Session {
|
||||||
|
id: "worker-b".to_string(),
|
||||||
|
task: "billing lane".to_string(),
|
||||||
|
agent_type: "claude".to_string(),
|
||||||
|
working_dir: repo_root.clone(),
|
||||||
|
state: SessionState::Idle,
|
||||||
|
pid: None,
|
||||||
|
worktree: None,
|
||||||
|
created_at: now - Duration::minutes(2),
|
||||||
|
updated_at: now - Duration::minutes(2),
|
||||||
|
metrics: SessionMetrics::default(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
db.send_message(
|
||||||
|
"lead",
|
||||||
|
"worker-a",
|
||||||
|
"{\"task\":\"Review auth flow\",\"context\":\"Delegated from lead\"}",
|
||||||
|
"task_handoff",
|
||||||
|
)?;
|
||||||
|
db.send_message(
|
||||||
|
"lead",
|
||||||
|
"worker-a",
|
||||||
|
"{\"task\":\"Check billing integration\",\"context\":\"Delegated from lead\"}",
|
||||||
|
"task_handoff",
|
||||||
|
)?;
|
||||||
|
db.send_message(
|
||||||
|
"lead",
|
||||||
|
"worker-b",
|
||||||
|
"{\"task\":\"Existing clear lane\",\"context\":\"Delegated from lead\"}",
|
||||||
|
"task_handoff",
|
||||||
|
)?;
|
||||||
|
let _ = db.mark_messages_read("worker-b")?;
|
||||||
|
|
||||||
|
let outcomes = rebalance_team_backlog(&db, &cfg, "lead", "claude", true, 5).await?;
|
||||||
|
assert_eq!(outcomes.len(), 1);
|
||||||
|
assert_eq!(outcomes[0].from_session_id, "worker-a");
|
||||||
|
assert_eq!(outcomes[0].session_id, "worker-b");
|
||||||
|
assert_eq!(outcomes[0].action, AssignmentAction::ReusedIdle);
|
||||||
|
|
||||||
|
let unread = db.unread_message_counts()?;
|
||||||
|
assert_eq!(unread.get("worker-a"), Some(&1));
|
||||||
|
assert_eq!(unread.get("worker-b"), Some(&1));
|
||||||
|
|
||||||
|
let worker_b_messages = db.list_messages_for_session("worker-b", 10)?;
|
||||||
|
assert!(worker_b_messages.iter().any(|message| {
|
||||||
|
message.msg_type == "task_handoff"
|
||||||
|
&& message.content.contains("Review auth flow")
|
||||||
|
}));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user