Compare commits

..

44 Commits

Author SHA1 Message Date
Affaan Mustafa 125d5e6199 feat: add ecc2 legacy plugin migration import 2026-04-10 11:53:17 -07:00
Affaan Mustafa 4ff5a7169f feat: add ecc2 legacy tool migration import 2026-04-10 11:49:38 -07:00
Affaan Mustafa cee82417db feat: add ecc2 legacy skill migration import 2026-04-10 11:41:36 -07:00
Affaan Mustafa f4b1b11e10 feat: add ecc2 legacy env migration import 2026-04-10 11:33:18 -07:00
Affaan Mustafa e7dd7047b5 feat: add ecc2 legacy remote migration import 2026-04-10 11:23:10 -07:00
Affaan Mustafa b6426ade32 feat: add ecc2 legacy workspace memory import 2026-04-10 11:10:40 -07:00
Affaan Mustafa 790cb0205c feat: add ecc2 legacy schedule migration import 2026-04-10 11:06:14 -07:00
Affaan Mustafa 046af44065 feat: add ecc2 legacy migration scaffold 2026-04-10 10:57:13 -07:00
Affaan Mustafa d36e9c48a4 feat: add ecc2 legacy migration plan 2026-04-10 10:54:49 -07:00
Affaan Mustafa 0f028f38f6 feat: add ecc2 legacy migration audit 2026-04-10 10:50:17 -07:00
Affaan Mustafa feee17ad02 feat: extend ecc2 harness marker coverage 2026-04-10 10:39:21 -07:00
Affaan Mustafa 7b7ec434df feat: add ecc2 package manager harness env 2026-04-10 10:33:07 -07:00
Affaan Mustafa 176efb7623 feat: add ecc2 harness compatibility env 2026-04-10 10:24:33 -07:00
Affaan Mustafa b51792fe0e feat: auto-resolve ecc2 harnesses from repo markers 2026-04-10 10:12:35 -07:00
Affaan Mustafa 050d9a9707 fix: honor ecc2 default agent in cli commands 2026-04-10 09:55:06 -07:00
Affaan Mustafa 03e52f49e8 feat: normalize ecc2 profiles across harnesses 2026-04-10 09:49:05 -07:00
Affaan Mustafa 30913b2cc4 feat: add ecc2 computer use remote dispatch 2026-04-10 09:40:01 -07:00
Affaan Mustafa 7809518612 feat: add ecc2 remote dispatch intake 2026-04-10 09:21:30 -07:00
Affaan Mustafa bbed46d3eb feat: detect custom ecc2 harness markers 2026-04-10 09:08:06 -07:00
Affaan Mustafa 4a1f3cbd3f feat: preserve custom ecc2 harness labels 2026-04-10 08:57:59 -07:00
Affaan Mustafa bcd869d520 feat: add ecc2 configurable harness runners 2026-04-10 08:45:47 -07:00
Affaan Mustafa 2e6eeafabd feat: add ecc2 persistent task scheduling 2026-04-10 08:31:04 -07:00
Affaan Mustafa 52371f5016 feat: prioritize ecc2 handoff queues 2026-04-10 08:16:17 -07:00
Affaan Mustafa d84c64fa0e feat: canonicalize ecc2 harness aliases 2026-04-10 08:03:25 -07:00
Affaan Mustafa a4aaa30e93 feat: add ecc2 gemini runner support 2026-04-10 07:58:26 -07:00
Affaan Mustafa 97afd95451 feat: add ecc2 codex and opencode runners 2026-04-10 07:53:54 -07:00
Affaan Mustafa 29ff44e23e feat: add ecc2 harness metadata detection 2026-04-10 07:46:46 -07:00
Affaan Mustafa 9c525009d7 feat: add ecc2 memory connector status reporting 2026-04-10 07:16:41 -07:00
Affaan Mustafa 9c294f7815 feat: add ecc2 pinned memory observations 2026-04-10 07:06:37 -07:00
Affaan Mustafa 766bf31737 feat: add ecc2 memory observation priorities 2026-04-10 06:56:26 -07:00
Affaan Mustafa 9523575721 feat: add ecc2 connector sync checkpoints 2026-04-10 06:44:05 -07:00
Affaan Mustafa 406722b5ef feat: add ecc2 markdown directory memory connector 2026-04-10 06:38:33 -07:00
Affaan Mustafa 5258a75382 feat: add ecc2 bulk memory connector sync 2026-04-10 06:34:40 -07:00
Affaan Mustafa 966af37f89 feat: add ecc2 dotenv memory connectors 2026-04-10 06:30:32 -07:00
Affaan Mustafa 22a5a8de6d feat: add ecc2 markdown memory connectors 2026-04-10 06:26:42 -07:00
Affaan Mustafa d3b680b6db feat: add ecc2 directory memory connectors 2026-04-10 06:20:15 -07:00
Affaan Mustafa d49ceacb7d feat: add ecc2 memory connectors 2026-04-10 06:14:13 -07:00
Affaan Mustafa 8cc92c59a6 feat: add ecc2 graph compaction 2026-04-10 06:07:12 -07:00
Affaan Mustafa 77c9082deb feat: add ecc2 graph observations 2026-04-10 06:02:24 -07:00
Affaan Mustafa 727d9380cb style: format ecc2 manager 2026-04-10 05:50:03 -07:00
Affaan Mustafa 7a13564a8b feat: add ecc2 graph recall memory ranking 2026-04-10 05:49:43 -07:00
Affaan Mustafa 23348a21a6 feat: preview ecc2 graph-aware routing 2026-04-10 04:49:14 -07:00
Affaan Mustafa 0b68af123c feat: route ecc2 delegates by graph context 2026-04-10 04:41:00 -07:00
Affaan Mustafa 4b1ff48219 feat: surface ecc2 graph context in metrics 2026-04-10 04:35:34 -07:00
12 changed files with 14217 additions and 172 deletions
+15
View File
@@ -183,6 +183,21 @@ It is mostly:
- clarifying public docs
- continuing the ECC 2.0 operator/control-plane buildout
ECC 2.0 now ships a bounded migration audit entrypoint:
- `ecc migrate audit --source ~/.hermes`
- `ecc migrate plan --source ~/.hermes --output migration-plan.md`
- `ecc migrate scaffold --source ~/.hermes --output-dir migration-artifacts`
- `ecc migrate import-skills --source ~/.hermes --output-dir migration-artifacts/skills`
- `ecc migrate import-tools --source ~/.hermes --output-dir migration-artifacts/tools`
- `ecc migrate import-plugins --source ~/.hermes --output-dir migration-artifacts/plugins`
- `ecc migrate import-schedules --source ~/.hermes --dry-run`
- `ecc migrate import-remote --source ~/.hermes --dry-run`
- `ecc migrate import-env --source ~/.hermes --dry-run`
- `ecc migrate import-memory --source ~/.hermes`
Use that first to inventory the legacy workspace and map detected surfaces onto the current ECC2 scheduler, remote dispatch, memory graph, templates, and manual-translation lanes.
## What Still Belongs In Backlog
The remaining large migration themes are already tracked:
+2
View File
@@ -82,6 +82,8 @@ These stay local and should be configured per operator:
## Suggested Bring-Up Order
0. Run `ecc migrate audit --source ~/.hermes` first to inventory the legacy workspace and see which parts already map onto ECC2.
0.5. Generate and review artifacts with `ecc migrate plan` / `ecc migrate scaffold`, scaffold reusable legacy skills with `ecc migrate import-skills --output-dir migration-artifacts/skills`, scaffold legacy tool translation templates with `ecc migrate import-tools --output-dir migration-artifacts/tools`, scaffold legacy bridge plugins with `ecc migrate import-plugins --output-dir migration-artifacts/plugins`, preview recurring jobs with `ecc migrate import-schedules --dry-run`, preview gateway dispatch with `ecc migrate import-remote --dry-run`, preview safe env/service context with `ecc migrate import-env --dry-run`, then import sanitized workspace memory with `ecc migrate import-memory`.
1. Install ECC and verify the baseline harness setup.
2. Install Hermes and point it at ECC-imported skills.
3. Register the MCP servers you actually use every day.
+12
View File
@@ -315,6 +315,17 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "cron"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8c3e73077b4b4a6ab1ea5047c37c57aee77657bc8ecd6f29b0af082d0b0c07"
dependencies = [
"chrono",
"nom",
"once_cell",
]
[[package]]
name = "crossterm"
version = "0.28.1"
@@ -507,6 +518,7 @@ dependencies = [
"anyhow",
"chrono",
"clap",
"cron",
"crossterm 0.28.1",
"dirs",
"git2",
+1
View File
@@ -43,6 +43,7 @@ libc = "0.2"
# Time
chrono = { version = "0.4", features = ["serde"] }
cron = "0.12"
# UUID for session IDs
uuid = { version = "1", features = ["v4"] }
+72 -2
View File
@@ -1,13 +1,41 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::fmt;
use crate::session::store::StateStore;
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum TaskPriority {
Low,
#[default]
Normal,
High,
Critical,
}
impl fmt::Display for TaskPriority {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let label = match self {
Self::Low => "low",
Self::Normal => "normal",
Self::High => "high",
Self::Critical => "critical",
};
write!(f, "{label}")
}
}
/// Message types for inter-agent communication.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum MessageType {
/// Task handoff from one agent to another
TaskHandoff { task: String, context: String },
TaskHandoff {
task: String,
context: String,
#[serde(default)]
priority: TaskPriority,
},
/// Agent requesting information from another
Query { question: String },
/// Response to a query
@@ -46,7 +74,16 @@ pub fn parse(content: &str) -> Option<MessageType> {
pub fn preview(msg_type: &str, content: &str) -> String {
match parse(content) {
Some(MessageType::TaskHandoff { task, .. }) => {
format!("handoff {}", truncate(&task, 56))
let priority = handoff_priority(content);
if priority == TaskPriority::Normal {
format!("handoff {}", truncate(&task, 56))
} else {
format!(
"handoff [{}] {}",
priority_label(priority),
truncate(&task, 48)
)
}
}
Some(MessageType::Query { question }) => {
format!("query {}", truncate(&question, 56))
@@ -75,6 +112,39 @@ pub fn preview(msg_type: &str, content: &str) -> String {
}
}
pub fn handoff_priority(content: &str) -> TaskPriority {
match parse(content) {
Some(MessageType::TaskHandoff { priority, .. }) => priority,
_ => extract_legacy_handoff_priority(content),
}
}
fn extract_legacy_handoff_priority(content: &str) -> TaskPriority {
let value: serde_json::Value = match serde_json::from_str(content) {
Ok(value) => value,
Err(_) => return TaskPriority::Normal,
};
match value
.get("priority")
.and_then(|priority| priority.as_str())
.unwrap_or("normal")
{
"low" => TaskPriority::Low,
"high" => TaskPriority::High,
"critical" => TaskPriority::Critical,
_ => TaskPriority::Normal,
}
}
fn priority_label(priority: TaskPriority) -> &'static str {
match priority {
TaskPriority::Low => "low",
TaskPriority::Normal => "normal",
TaskPriority::High => "high",
TaskPriority::Critical => "critical",
}
}
fn truncate(value: &str, max_chars: usize) -> String {
let trimmed = value.trim();
if trimmed.chars().count() <= max_chars {
+427 -2
View File
@@ -50,6 +50,16 @@ pub struct ConflictResolutionConfig {
pub notify_lead: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ComputerUseDispatchConfig {
pub agent: Option<String>,
pub profile: Option<String>,
pub use_worktree: bool,
pub project: Option<String>,
pub task_group: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct AgentProfileConfig {
@@ -79,6 +89,27 @@ pub struct ResolvedAgentProfile {
pub append_system_prompt: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct HarnessRunnerConfig {
pub program: String,
pub base_args: Vec<String>,
pub project_markers: Vec<PathBuf>,
pub cwd_flag: Option<String>,
pub session_name_flag: Option<String>,
pub task_flag: Option<String>,
pub model_flag: Option<String>,
pub add_dir_flag: Option<String>,
pub include_directories_flag: Option<String>,
pub allowed_tools_flag: Option<String>,
pub disallowed_tools_flag: Option<String>,
pub permission_mode_flag: Option<String>,
pub max_budget_usd_flag: Option<String>,
pub append_system_prompt_flag: Option<String>,
pub inline_system_prompt_for_task: bool,
pub env: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct OrchestrationTemplateConfig {
@@ -103,6 +134,67 @@ pub struct OrchestrationTemplateStepConfig {
pub task_group: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum MemoryConnectorConfig {
JsonlFile(MemoryConnectorJsonlFileConfig),
JsonlDirectory(MemoryConnectorJsonlDirectoryConfig),
MarkdownFile(MemoryConnectorMarkdownFileConfig),
MarkdownDirectory(MemoryConnectorMarkdownDirectoryConfig),
DotenvFile(MemoryConnectorDotenvFileConfig),
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct MemoryConnectorJsonlFileConfig {
pub path: PathBuf,
pub session_id: Option<String>,
pub default_entity_type: Option<String>,
pub default_observation_type: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct MemoryConnectorJsonlDirectoryConfig {
pub path: PathBuf,
pub recurse: bool,
pub session_id: Option<String>,
pub default_entity_type: Option<String>,
pub default_observation_type: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct MemoryConnectorMarkdownFileConfig {
pub path: PathBuf,
pub session_id: Option<String>,
pub default_entity_type: Option<String>,
pub default_observation_type: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct MemoryConnectorMarkdownDirectoryConfig {
pub path: PathBuf,
pub recurse: bool,
pub session_id: Option<String>,
pub default_entity_type: Option<String>,
pub default_observation_type: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct MemoryConnectorDotenvFileConfig {
pub path: PathBuf,
pub session_id: Option<String>,
pub default_entity_type: Option<String>,
pub default_observation_type: Option<String>,
pub key_prefixes: Vec<String>,
pub include_keys: Vec<String>,
pub exclude_keys: Vec<String>,
pub include_safe_values: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolvedOrchestrationTemplate {
pub template_name: String,
@@ -137,8 +229,11 @@ pub struct Config {
pub auto_terminate_stale_sessions: bool,
pub default_agent: String,
pub default_agent_profile: Option<String>,
pub harness_runners: BTreeMap<String, HarnessRunnerConfig>,
pub agent_profiles: BTreeMap<String, AgentProfileConfig>,
pub orchestration_templates: BTreeMap<String, OrchestrationTemplateConfig>,
pub memory_connectors: BTreeMap<String, MemoryConnectorConfig>,
pub computer_use_dispatch: ComputerUseDispatchConfig,
pub auto_dispatch_unread_handoffs: bool,
pub auto_dispatch_limit_per_session: usize,
pub auto_create_worktrees: bool,
@@ -201,8 +296,11 @@ impl Default for Config {
auto_terminate_stale_sessions: false,
default_agent: "claude".to_string(),
default_agent_profile: None,
harness_runners: BTreeMap::new(),
agent_profiles: BTreeMap::new(),
orchestration_templates: BTreeMap::new(),
memory_connectors: BTreeMap::new(),
computer_use_dispatch: ComputerUseDispatchConfig::default(),
auto_dispatch_unread_handoffs: false,
auto_dispatch_limit_per_session: 5,
auto_create_worktrees: true,
@@ -261,11 +359,36 @@ impl Config {
self.budget_alert_thresholds.sanitized()
}
pub fn computer_use_dispatch_defaults(&self) -> ResolvedComputerUseDispatchConfig {
let agent = self
.computer_use_dispatch
.agent
.clone()
.unwrap_or_else(|| self.default_agent.clone());
let profile = self
.computer_use_dispatch
.profile
.clone()
.or_else(|| self.default_agent_profile.clone());
ResolvedComputerUseDispatchConfig {
agent,
profile,
use_worktree: self.computer_use_dispatch.use_worktree,
project: self.computer_use_dispatch.project.clone(),
task_group: self.computer_use_dispatch.task_group.clone(),
}
}
pub fn resolve_agent_profile(&self, name: &str) -> Result<ResolvedAgentProfile> {
let mut chain = Vec::new();
self.resolve_agent_profile_inner(name, &mut chain)
}
pub fn harness_runner(&self, harness: &str) -> Option<&HarnessRunnerConfig> {
let key = harness.trim().to_ascii_lowercase();
self.harness_runners.get(&key)
}
pub fn resolve_orchestration_template(
&self,
name: &str,
@@ -657,6 +780,50 @@ impl ResolvedAgentProfile {
}
}
impl Default for HarnessRunnerConfig {
fn default() -> Self {
Self {
program: String::new(),
base_args: Vec::new(),
project_markers: Vec::new(),
cwd_flag: None,
session_name_flag: None,
task_flag: None,
model_flag: None,
add_dir_flag: None,
include_directories_flag: None,
allowed_tools_flag: None,
disallowed_tools_flag: None,
permission_mode_flag: None,
max_budget_usd_flag: None,
append_system_prompt_flag: None,
inline_system_prompt_for_task: true,
env: BTreeMap::new(),
}
}
}
impl Default for ComputerUseDispatchConfig {
fn default() -> Self {
Self {
agent: None,
profile: None,
use_worktree: false,
project: None,
task_group: None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ResolvedComputerUseDispatchConfig {
pub agent: String,
pub profile: Option<String>,
pub use_worktree: bool,
pub project: Option<String>,
pub task_group: Option<String>,
}
fn merge_unique<T>(base: &mut Vec<T>, additions: &[T])
where
T: Clone + PartialEq,
@@ -737,8 +904,8 @@ impl BudgetAlertThresholds {
#[cfg(test)]
mod tests {
use super::{
BudgetAlertThresholds, Config, ConflictResolutionConfig, ConflictResolutionStrategy,
PaneLayout,
BudgetAlertThresholds, ComputerUseDispatchConfig, Config, ConflictResolutionConfig,
ConflictResolutionStrategy, PaneLayout, ResolvedComputerUseDispatchConfig,
};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::collections::BTreeMap;
@@ -1088,6 +1255,42 @@ notify_lead = false
);
}
#[test]
fn computer_use_dispatch_deserializes_from_toml() {
let config: Config = toml::from_str(
r#"
[computer_use_dispatch]
agent = "codex"
profile = "browser"
use_worktree = true
project = "ops"
task_group = "remote browser"
"#,
)
.unwrap();
assert_eq!(
config.computer_use_dispatch,
ComputerUseDispatchConfig {
agent: Some("codex".to_string()),
profile: Some("browser".to_string()),
use_worktree: true,
project: Some("ops".to_string()),
task_group: Some("remote browser".to_string()),
}
);
assert_eq!(
config.computer_use_dispatch_defaults(),
ResolvedComputerUseDispatchConfig {
agent: "codex".to_string(),
profile: Some("browser".to_string()),
use_worktree: true,
project: Some("ops".to_string()),
task_group: Some("remote browser".to_string()),
}
);
}
#[test]
fn agent_profiles_resolve_inheritance_and_defaults() {
let config: Config = toml::from_str(
@@ -1147,6 +1350,49 @@ inherits = "a"
.contains("agent profile inheritance cycle"));
}
#[test]
fn harness_runners_deserialize_from_toml() {
let config: Config = toml::from_str(
r#"
[harness_runners.cursor]
program = "cursor-agent"
base_args = ["run"]
project_markers = [".cursor", ".cursor/rules"]
cwd_flag = "--cwd"
session_name_flag = "--name"
task_flag = "--task"
model_flag = "--model"
permission_mode_flag = "--permission-mode"
inline_system_prompt_for_task = true
[harness_runners.cursor.env]
ECC_HARNESS = "cursor"
"#,
)
.unwrap();
let runner = config.harness_runner("cursor").expect("cursor runner");
assert_eq!(runner.program, "cursor-agent");
assert_eq!(runner.base_args, vec!["run"]);
assert_eq!(
runner.project_markers,
vec![PathBuf::from(".cursor"), PathBuf::from(".cursor/rules")]
);
assert_eq!(runner.cwd_flag.as_deref(), Some("--cwd"));
assert_eq!(runner.session_name_flag.as_deref(), Some("--name"));
assert_eq!(runner.task_flag.as_deref(), Some("--task"));
assert_eq!(runner.model_flag.as_deref(), Some("--model"));
assert_eq!(
runner.permission_mode_flag.as_deref(),
Some("--permission-mode")
);
assert!(runner.inline_system_prompt_for_task);
assert_eq!(
runner.env.get("ECC_HARNESS").map(String::as_str),
Some("cursor")
);
}
#[test]
fn orchestration_templates_resolve_steps_and_interpolate_variables() {
let config: Config = toml::from_str(
@@ -1231,6 +1477,185 @@ task = "Plan {{task}} for {{component}}"
assert!(error_text.contains("missing orchestration template variable(s): component"));
}
#[test]
fn memory_connectors_deserialize_from_toml() {
let config: Config = toml::from_str(
r#"
[memory_connectors.hermes_notes]
kind = "jsonl_file"
path = "/tmp/hermes-memory.jsonl"
session_id = "latest"
default_entity_type = "incident"
default_observation_type = "external_note"
"#,
)
.unwrap();
let connector = config
.memory_connectors
.get("hermes_notes")
.expect("connector should deserialize");
match connector {
crate::config::MemoryConnectorConfig::JsonlFile(settings) => {
assert_eq!(settings.path, PathBuf::from("/tmp/hermes-memory.jsonl"));
assert_eq!(settings.session_id.as_deref(), Some("latest"));
assert_eq!(settings.default_entity_type.as_deref(), Some("incident"));
assert_eq!(
settings.default_observation_type.as_deref(),
Some("external_note")
);
}
_ => panic!("expected jsonl_file connector"),
}
}
#[test]
fn memory_jsonl_directory_connectors_deserialize_from_toml() {
let config: Config = toml::from_str(
r#"
[memory_connectors.hermes_dir]
kind = "jsonl_directory"
path = "/tmp/hermes-memory"
recurse = true
default_entity_type = "incident"
default_observation_type = "external_note"
"#,
)
.unwrap();
let connector = config
.memory_connectors
.get("hermes_dir")
.expect("connector should deserialize");
match connector {
crate::config::MemoryConnectorConfig::JsonlDirectory(settings) => {
assert_eq!(settings.path, PathBuf::from("/tmp/hermes-memory"));
assert!(settings.recurse);
assert_eq!(settings.default_entity_type.as_deref(), Some("incident"));
assert_eq!(
settings.default_observation_type.as_deref(),
Some("external_note")
);
}
_ => panic!("expected jsonl_directory connector"),
}
}
#[test]
fn memory_markdown_file_connectors_deserialize_from_toml() {
let config: Config = toml::from_str(
r#"
[memory_connectors.workspace_note]
kind = "markdown_file"
path = "/tmp/hermes-memory.md"
session_id = "latest"
default_entity_type = "note_section"
default_observation_type = "external_note"
"#,
)
.unwrap();
let connector = config
.memory_connectors
.get("workspace_note")
.expect("connector should deserialize");
match connector {
crate::config::MemoryConnectorConfig::MarkdownFile(settings) => {
assert_eq!(settings.path, PathBuf::from("/tmp/hermes-memory.md"));
assert_eq!(settings.session_id.as_deref(), Some("latest"));
assert_eq!(
settings.default_entity_type.as_deref(),
Some("note_section")
);
assert_eq!(
settings.default_observation_type.as_deref(),
Some("external_note")
);
}
_ => panic!("expected markdown_file connector"),
}
}
#[test]
fn memory_markdown_directory_connectors_deserialize_from_toml() {
let config: Config = toml::from_str(
r#"
[memory_connectors.workspace_notes]
kind = "markdown_directory"
path = "/tmp/hermes-memory"
recurse = true
session_id = "latest"
default_entity_type = "note_section"
default_observation_type = "external_note"
"#,
)
.unwrap();
let connector = config
.memory_connectors
.get("workspace_notes")
.expect("connector should deserialize");
match connector {
crate::config::MemoryConnectorConfig::MarkdownDirectory(settings) => {
assert_eq!(settings.path, PathBuf::from("/tmp/hermes-memory"));
assert!(settings.recurse);
assert_eq!(settings.session_id.as_deref(), Some("latest"));
assert_eq!(
settings.default_entity_type.as_deref(),
Some("note_section")
);
assert_eq!(
settings.default_observation_type.as_deref(),
Some("external_note")
);
}
_ => panic!("expected markdown_directory connector"),
}
}
#[test]
fn memory_dotenv_file_connectors_deserialize_from_toml() {
let config: Config = toml::from_str(
r#"
[memory_connectors.hermes_env]
kind = "dotenv_file"
path = "/tmp/hermes.env"
session_id = "latest"
default_entity_type = "service_config"
default_observation_type = "external_config"
key_prefixes = ["STRIPE_", "PUBLIC_"]
include_keys = ["PUBLIC_BASE_URL"]
exclude_keys = ["STRIPE_WEBHOOK_SECRET"]
include_safe_values = true
"#,
)
.unwrap();
let connector = config
.memory_connectors
.get("hermes_env")
.expect("connector should deserialize");
match connector {
crate::config::MemoryConnectorConfig::DotenvFile(settings) => {
assert_eq!(settings.path, PathBuf::from("/tmp/hermes.env"));
assert_eq!(settings.session_id.as_deref(), Some("latest"));
assert_eq!(
settings.default_entity_type.as_deref(),
Some("service_config")
);
assert_eq!(
settings.default_observation_type.as_deref(),
Some("external_config")
);
assert_eq!(settings.key_prefixes, vec!["STRIPE_", "PUBLIC_"]);
assert_eq!(settings.include_keys, vec!["PUBLIC_BASE_URL"]);
assert_eq!(settings.exclude_keys, vec!["STRIPE_WEBHOOK_SECRET"]);
assert!(settings.include_safe_values);
}
_ => panic!("expected dotenv_file connector"),
}
}
#[test]
fn completion_summary_notifications_deserialize_from_toml() {
let config: Config = toml::from_str(
+8168 -57
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -27,6 +27,14 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
tracing::error!("Session check failed: {e}");
}
if let Err(e) = maybe_run_due_schedules(&db, &cfg).await {
tracing::error!("Scheduled task dispatch pass failed: {e}");
}
if let Err(e) = maybe_run_remote_dispatch(&db, &cfg).await {
tracing::error!("Remote dispatch pass failed: {e}");
}
if let Err(e) = coordinate_backlog_cycle(&db, &cfg).await {
tracing::error!("Backlog coordination pass failed: {e}");
}
@@ -89,6 +97,33 @@ fn check_sessions(db: &StateStore, cfg: &Config) -> Result<()> {
Ok(())
}
async fn maybe_run_due_schedules(db: &StateStore, cfg: &Config) -> Result<usize> {
let outcomes = manager::run_due_schedules(db, cfg, cfg.max_parallel_sessions).await?;
if !outcomes.is_empty() {
tracing::info!("Dispatched {} scheduled task(s)", outcomes.len());
}
Ok(outcomes.len())
}
async fn maybe_run_remote_dispatch(db: &StateStore, cfg: &Config) -> Result<usize> {
let outcomes =
manager::run_remote_dispatch_requests(db, cfg, cfg.max_parallel_sessions).await?;
let routed = outcomes
.iter()
.filter(|outcome| {
matches!(
outcome.action,
manager::RemoteDispatchAction::SpawnedTopLevel
| manager::RemoteDispatchAction::Assigned(_)
)
})
.count();
if routed > 0 {
tracing::info!("Dispatched {} remote request(s)", routed);
}
Ok(routed)
}
async fn maybe_auto_dispatch(db: &StateStore, cfg: &Config) -> Result<usize> {
let summary = maybe_auto_dispatch_with_recorder(
cfg,
File diff suppressed because it is too large Load Diff
+726
View File
@@ -13,6 +13,294 @@ use std::path::PathBuf;
pub type SessionAgentProfile = crate::config::ResolvedAgentProfile;
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum HarnessKind {
#[default]
Unknown,
Claude,
Codex,
OpenCode,
Gemini,
Cursor,
Kiro,
Trae,
Zed,
FactoryDroid,
Windsurf,
}
impl HarnessKind {
pub fn from_agent_type(agent_type: &str) -> Self {
match agent_type.trim().to_ascii_lowercase().as_str() {
"claude" | "claude-code" => Self::Claude,
"codex" => Self::Codex,
"opencode" => Self::OpenCode,
"gemini" | "gemini-cli" => Self::Gemini,
"cursor" => Self::Cursor,
"kiro" => Self::Kiro,
"trae" => Self::Trae,
"zed" => Self::Zed,
"factory-droid" | "factory_droid" | "factorydroid" => Self::FactoryDroid,
"windsurf" => Self::Windsurf,
_ => Self::Unknown,
}
}
pub fn from_db_value(value: &str) -> Self {
match value.trim().to_ascii_lowercase().as_str() {
"claude" => Self::Claude,
"codex" => Self::Codex,
"opencode" => Self::OpenCode,
"gemini" => Self::Gemini,
"cursor" => Self::Cursor,
"kiro" => Self::Kiro,
"trae" => Self::Trae,
"zed" => Self::Zed,
"factory_droid" => Self::FactoryDroid,
"windsurf" => Self::Windsurf,
_ => Self::Unknown,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Unknown => "unknown",
Self::Claude => "claude",
Self::Codex => "codex",
Self::OpenCode => "opencode",
Self::Gemini => "gemini",
Self::Cursor => "cursor",
Self::Kiro => "kiro",
Self::Trae => "trae",
Self::Zed => "zed",
Self::FactoryDroid => "factory_droid",
Self::Windsurf => "windsurf",
}
}
pub fn canonical_agent_type(agent_type: &str) -> String {
match Self::from_agent_type(agent_type) {
Self::Unknown => agent_type.trim().to_ascii_lowercase(),
harness => harness.as_str().to_string(),
}
}
fn supports_direct_execution(self) -> bool {
matches!(
self,
Self::Claude | Self::Codex | Self::OpenCode | Self::Gemini
)
}
fn project_markers(self) -> &'static [&'static str] {
match self {
Self::Claude => &[".claude"],
Self::Codex => &[".codex", ".codex-plugin"],
Self::OpenCode => &[".opencode"],
Self::Gemini => &[".gemini"],
Self::Cursor => &[".cursor"],
Self::Kiro => &[".kiro"],
Self::Trae => &[".trae"],
Self::Zed => &[".zed"],
Self::FactoryDroid => &[".factory-droid", ".factory_droid"],
Self::Windsurf => &[".windsurf"],
Self::Unknown => &[],
}
}
}
impl fmt::Display for HarnessKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionHarnessInfo {
pub primary: HarnessKind,
pub primary_label: String,
pub detected: Vec<HarnessKind>,
pub detected_labels: Vec<String>,
}
impl SessionHarnessInfo {
fn detected_labels_for(detected: &[HarnessKind]) -> Vec<String> {
detected.iter().map(|harness| harness.to_string()).collect()
}
fn configured_detected_labels(cfg: &crate::config::Config, working_dir: &Path) -> Vec<String> {
let mut labels = Vec::new();
for (name, runner) in &cfg.harness_runners {
if runner.project_markers.is_empty() {
continue;
}
if runner
.project_markers
.iter()
.any(|marker| working_dir.join(marker).exists())
{
let label = Self::runner_key(name);
if !label.is_empty() && !labels.contains(&label) {
labels.push(label);
}
}
}
labels
}
pub fn runner_key(agent_type: &str) -> String {
let canonical = HarnessKind::canonical_agent_type(agent_type);
match HarnessKind::from_agent_type(&canonical) {
HarnessKind::Unknown if canonical.is_empty() => {
HarnessKind::Unknown.as_str().to_string()
}
HarnessKind::Unknown => canonical,
harness => harness.as_str().to_string(),
}
}
fn primary_label_for(agent_type: &str, primary: HarnessKind) -> String {
match primary {
HarnessKind::Unknown => {
let label = Self::runner_key(agent_type);
if label.is_empty() {
HarnessKind::Unknown.as_str().to_string()
} else {
label
}
}
harness => harness.as_str().to_string(),
}
}
pub fn detect(agent_type: &str, working_dir: &Path) -> Self {
let runner_key = Self::runner_key(agent_type);
let detected = [
HarnessKind::Claude,
HarnessKind::Codex,
HarnessKind::OpenCode,
HarnessKind::Gemini,
HarnessKind::Cursor,
HarnessKind::Kiro,
HarnessKind::Trae,
HarnessKind::Zed,
HarnessKind::FactoryDroid,
HarnessKind::Windsurf,
]
.into_iter()
.filter(|harness| {
harness
.project_markers()
.iter()
.any(|marker| working_dir.join(marker).exists())
})
.collect::<Vec<_>>();
let primary = match HarnessKind::from_agent_type(&runner_key) {
HarnessKind::Unknown if runner_key == HarnessKind::Unknown.as_str() => {
detected.first().copied().unwrap_or(HarnessKind::Unknown)
}
HarnessKind::Unknown => HarnessKind::Unknown,
harness => harness,
};
let detected_labels = Self::detected_labels_for(&detected);
Self {
primary,
primary_label: Self::primary_label_for(agent_type, primary),
detected,
detected_labels,
}
}
pub fn from_persisted(
harness_label: &str,
agent_type: &str,
working_dir: &Path,
detected: Vec<HarnessKind>,
) -> Self {
let primary = HarnessKind::from_db_value(harness_label);
if primary == HarnessKind::Unknown && detected.is_empty() && harness_label.trim().is_empty()
{
return Self::detect(agent_type, working_dir);
}
let normalized_label = harness_label.trim().to_ascii_lowercase();
let detected_labels = Self::detected_labels_for(&detected);
Self {
primary,
primary_label: if normalized_label.is_empty() {
Self::primary_label_for(agent_type, primary)
} else {
normalized_label
},
detected,
detected_labels,
}
}
pub fn with_config_detection(
mut self,
cfg: &crate::config::Config,
working_dir: &Path,
) -> Self {
for label in Self::configured_detected_labels(cfg, working_dir) {
if !self.detected_labels.contains(&label) {
self.detected_labels.push(label);
}
}
if self.primary == HarnessKind::Unknown
&& self.primary_label == HarnessKind::Unknown.as_str()
&& !self.detected_labels.is_empty()
{
self.primary_label = self.detected_labels[0].clone();
}
self
}
pub fn resolve_requested_agent_type(
cfg: &crate::config::Config,
requested_agent_type: &str,
working_dir: &Path,
) -> String {
let canonical = HarnessKind::canonical_agent_type(requested_agent_type);
if !canonical.is_empty() && canonical != "auto" {
return canonical;
}
let detected = Self::detect("", working_dir).with_config_detection(cfg, working_dir);
if detected.primary_label != HarnessKind::Unknown.as_str()
&& Self::can_launch_detected_label(cfg, &detected.primary_label)
{
return Self::runner_key(&detected.primary_label);
}
for label in &detected.detected_labels {
if Self::can_launch_detected_label(cfg, label) {
return Self::runner_key(label);
}
}
HarnessKind::Claude.as_str().to_string()
}
fn can_launch_detected_label(cfg: &crate::config::Config, label: &str) -> bool {
cfg.harness_runner(label).is_some()
|| HarnessKind::from_agent_type(label).supports_direct_execution()
}
pub fn detected_summary(&self) -> String {
if self.detected_labels.is_empty() {
"none detected".to_string()
} else {
self.detected_labels.join(", ")
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: String,
@@ -134,6 +422,101 @@ pub struct SessionMessage {
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ScheduledTask {
pub id: i64,
pub cron_expr: String,
pub task: String,
pub agent_type: String,
pub profile_name: Option<String>,
pub working_dir: PathBuf,
pub project: String,
pub task_group: String,
pub use_worktree: bool,
pub last_run_at: Option<DateTime<Utc>>,
pub next_run_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RemoteDispatchRequest {
pub id: i64,
pub request_kind: RemoteDispatchKind,
pub target_session_id: Option<String>,
pub task: String,
pub target_url: Option<String>,
pub priority: crate::comms::TaskPriority,
pub agent_type: String,
pub profile_name: Option<String>,
pub working_dir: PathBuf,
pub project: String,
pub task_group: String,
pub use_worktree: bool,
pub source: String,
pub requester: Option<String>,
pub status: RemoteDispatchStatus,
pub result_session_id: Option<String>,
pub result_action: Option<String>,
pub error: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub dispatched_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RemoteDispatchKind {
Standard,
ComputerUse,
}
impl fmt::Display for RemoteDispatchKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Standard => write!(f, "standard"),
Self::ComputerUse => write!(f, "computer_use"),
}
}
}
impl RemoteDispatchKind {
pub fn from_db_value(value: &str) -> Self {
match value {
"computer_use" => Self::ComputerUse,
_ => Self::Standard,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RemoteDispatchStatus {
Pending,
Dispatched,
Failed,
}
impl fmt::Display for RemoteDispatchStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Pending => write!(f, "pending"),
Self::Dispatched => write!(f, "dispatched"),
Self::Failed => write!(f, "failed"),
}
}
}
impl RemoteDispatchStatus {
pub fn from_db_value(value: &str) -> Self {
match value {
"dispatched" => Self::Dispatched,
"failed" => Self::Failed,
_ => Self::Pending,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FileActivityEntry {
pub session_id: String,
@@ -190,6 +573,78 @@ pub struct ContextGraphEntityDetail {
pub incoming: Vec<ContextGraphRelation>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContextGraphObservation {
pub id: i64,
pub session_id: Option<String>,
pub entity_id: i64,
pub entity_type: String,
pub entity_name: String,
pub observation_type: String,
pub priority: ContextObservationPriority,
pub pinned: bool,
pub summary: String,
pub details: BTreeMap<String, String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContextGraphRecallEntry {
pub entity: ContextGraphEntity,
pub score: u64,
pub matched_terms: Vec<String>,
pub relation_count: usize,
pub observation_count: usize,
pub max_observation_priority: ContextObservationPriority,
pub has_pinned_observation: bool,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum ContextObservationPriority {
Low,
Normal,
High,
Critical,
}
impl Default for ContextObservationPriority {
fn default() -> Self {
Self::Normal
}
}
impl fmt::Display for ContextObservationPriority {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Low => write!(f, "low"),
Self::Normal => write!(f, "normal"),
Self::High => write!(f, "high"),
Self::Critical => write!(f, "critical"),
}
}
}
impl ContextObservationPriority {
pub fn from_db_value(value: i64) -> Self {
match value {
0 => Self::Low,
2 => Self::High,
3 => Self::Critical,
_ => Self::Normal,
}
}
pub fn as_db_value(self) -> i64 {
match self {
Self::Low => 0,
Self::Normal => 1,
Self::High => 2,
Self::Critical => 3,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContextGraphSyncStats {
pub sessions_scanned: usize,
@@ -198,6 +653,14 @@ pub struct ContextGraphSyncStats {
pub messages_processed: usize,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContextGraphCompactionStats {
pub entities_scanned: usize,
pub duplicate_observations_deleted: usize,
pub overflow_observations_deleted: usize,
pub observations_retained: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FileActivityAction {
@@ -235,3 +698,266 @@ pub struct SessionGrouping {
pub project: Option<String>,
pub task_group: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
struct TestDir {
path: PathBuf,
}
impl TestDir {
fn new(label: &str) -> Result<Self, Box<dyn std::error::Error>> {
let path =
std::env::temp_dir().join(format!("ecc2-{}-{}", label, uuid::Uuid::new_v4()));
fs::create_dir_all(&path)?;
Ok(Self { path })
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
#[test]
fn detect_session_harness_prefers_agent_type_and_collects_project_markers(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-detect")?;
fs::create_dir_all(repo.path().join(".codex"))?;
fs::create_dir_all(repo.path().join(".claude"))?;
let harness = SessionHarnessInfo::detect("claude", repo.path());
assert_eq!(harness.primary, HarnessKind::Claude);
assert_eq!(harness.primary_label, "claude");
assert_eq!(
harness.detected,
vec![HarnessKind::Claude, HarnessKind::Codex]
);
assert_eq!(harness.detected_labels, vec!["claude", "codex"]);
assert_eq!(harness.detected_summary(), "claude, codex");
Ok(())
}
#[test]
fn detect_session_harness_falls_back_to_project_markers_when_agent_unspecified(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-markers")?;
fs::create_dir_all(repo.path().join(".gemini"))?;
let harness = SessionHarnessInfo::detect("", repo.path());
assert_eq!(harness.primary, HarnessKind::Gemini);
assert_eq!(harness.primary_label, "gemini");
assert_eq!(harness.detected, vec![HarnessKind::Gemini]);
assert_eq!(harness.detected_labels, vec!["gemini"]);
Ok(())
}
#[test]
fn detect_session_harness_collects_extended_builtin_markers(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-extended-markers")?;
fs::create_dir_all(repo.path().join(".zed"))?;
fs::create_dir_all(repo.path().join(".factory-droid"))?;
fs::create_dir_all(repo.path().join(".windsurf"))?;
let harness = SessionHarnessInfo::detect("", repo.path());
assert_eq!(harness.primary, HarnessKind::Zed);
assert_eq!(harness.primary_label, "zed");
assert_eq!(
harness.detected,
vec![
HarnessKind::Zed,
HarnessKind::FactoryDroid,
HarnessKind::Windsurf
]
);
assert_eq!(
harness.detected_labels,
vec!["zed", "factory_droid", "windsurf"]
);
Ok(())
}
#[test]
fn canonical_agent_type_normalizes_known_aliases() {
assert_eq!(HarnessKind::canonical_agent_type("claude-code"), "claude");
assert_eq!(HarnessKind::canonical_agent_type("gemini-cli"), "gemini");
assert_eq!(
HarnessKind::canonical_agent_type("factory-droid"),
"factory_droid"
);
assert_eq!(
HarnessKind::canonical_agent_type(" custom-runner "),
"custom-runner"
);
}
#[test]
fn detect_session_harness_preserves_custom_agent_label_without_markers() {
let harness = SessionHarnessInfo::detect(" custom-runner ", Path::new("."));
assert_eq!(harness.primary, HarnessKind::Unknown);
assert_eq!(harness.primary_label, "custom-runner");
assert!(harness.detected.is_empty());
assert!(harness.detected_labels.is_empty());
}
#[test]
fn detect_session_harness_preserves_custom_agent_label_with_project_markers(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-custom-markers")?;
fs::create_dir_all(repo.path().join(".claude"))?;
fs::create_dir_all(repo.path().join(".codex"))?;
let harness = SessionHarnessInfo::detect("custom-runner", repo.path());
assert_eq!(harness.primary, HarnessKind::Unknown);
assert_eq!(harness.primary_label, "custom-runner");
assert_eq!(
harness.detected,
vec![HarnessKind::Claude, HarnessKind::Codex]
);
assert_eq!(harness.detected_labels, vec!["claude", "codex"]);
Ok(())
}
#[test]
fn config_detection_adds_custom_markers_to_detected_summary(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-custom-config")?;
fs::create_dir_all(repo.path().join(".acme"))?;
let mut cfg = crate::config::Config::default();
cfg.harness_runners.insert(
"acme-runner".to_string(),
crate::config::HarnessRunnerConfig {
project_markers: vec![PathBuf::from(".acme")],
..Default::default()
},
);
let harness =
SessionHarnessInfo::detect("", repo.path()).with_config_detection(&cfg, repo.path());
assert_eq!(harness.primary, HarnessKind::Unknown);
assert_eq!(harness.primary_label, "acme-runner");
assert_eq!(harness.detected_labels, vec!["acme-runner"]);
assert_eq!(harness.detected_summary(), "acme-runner");
Ok(())
}
#[test]
fn config_detection_preserves_custom_primary_label_and_appends_marker_matches(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-config-append")?;
fs::create_dir_all(repo.path().join(".acme"))?;
fs::create_dir_all(repo.path().join(".codex"))?;
let mut cfg = crate::config::Config::default();
cfg.harness_runners.insert(
"acme-runner".to_string(),
crate::config::HarnessRunnerConfig {
project_markers: vec![PathBuf::from(".acme")],
..Default::default()
},
);
let harness = SessionHarnessInfo::detect("acme-runner", repo.path())
.with_config_detection(&cfg, repo.path());
assert_eq!(harness.primary, HarnessKind::Unknown);
assert_eq!(harness.primary_label, "acme-runner");
assert_eq!(harness.detected_labels, vec!["codex", "acme-runner"]);
assert_eq!(harness.detected_summary(), "codex, acme-runner");
Ok(())
}
#[test]
fn runner_key_uses_canonical_label_for_unknown_harnesses() {
assert_eq!(
SessionHarnessInfo::runner_key(" custom-runner "),
"custom-runner"
);
assert_eq!(SessionHarnessInfo::runner_key("claude-code"), "claude");
}
#[test]
fn resolve_requested_agent_type_uses_detected_builtin_marker_for_auto(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-resolve-auto-built-in")?;
fs::create_dir_all(repo.path().join(".codex"))?;
let resolved = SessionHarnessInfo::resolve_requested_agent_type(
&crate::config::Config::default(),
"auto",
repo.path(),
);
assert_eq!(resolved, "codex");
Ok(())
}
#[test]
fn resolve_requested_agent_type_uses_configured_marker_for_auto(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-resolve-auto-custom")?;
fs::create_dir_all(repo.path().join(".acme"))?;
let mut cfg = crate::config::Config::default();
cfg.harness_runners.insert(
"acme-runner".to_string(),
crate::config::HarnessRunnerConfig {
project_markers: vec![PathBuf::from(".acme")],
..Default::default()
},
);
let resolved = SessionHarnessInfo::resolve_requested_agent_type(&cfg, "auto", repo.path());
assert_eq!(resolved, "acme-runner");
Ok(())
}
#[test]
fn resolve_requested_agent_type_skips_nonlaunchable_builtin_markers_without_runner(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-resolve-auto-nonlaunchable")?;
fs::create_dir_all(repo.path().join(".zed"))?;
let resolved = SessionHarnessInfo::resolve_requested_agent_type(
&crate::config::Config::default(),
"auto",
repo.path(),
);
assert_eq!(resolved, "claude");
Ok(())
}
#[test]
fn resolve_requested_agent_type_uses_configured_runner_for_extended_builtin_markers(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-resolve-auto-extended-runner")?;
fs::create_dir_all(repo.path().join(".windsurf"))?;
let mut cfg = crate::config::Config::default();
cfg.harness_runners.insert(
"windsurf".to_string(),
crate::config::HarnessRunnerConfig {
program: "windsurf".to_string(),
..Default::default()
},
);
let resolved = SessionHarnessInfo::resolve_requested_agent_type(&cfg, "auto", repo.path());
assert_eq!(resolved, "windsurf");
Ok(())
}
#[test]
fn resolve_requested_agent_type_falls_back_to_claude_without_markers() {
let resolved = SessionHarnessInfo::resolve_requested_agent_type(
&crate::config::Config::default(),
"auto",
Path::new("."),
);
assert_eq!(resolved, "claude");
}
}
+1779 -22
View File
File diff suppressed because it is too large Load Diff
+659 -14
View File
@@ -23,7 +23,8 @@ use crate::session::output::{
};
use crate::session::store::{DaemonActivity, FileActivityOverlap, StateStore};
use crate::session::{
DecisionLogEntry, FileActivityEntry, Session, SessionGrouping, SessionMessage, SessionState,
ContextObservationPriority, DecisionLogEntry, FileActivityEntry, Session, SessionGrouping,
SessionHarnessInfo, SessionMessage, SessionState,
};
use crate::worktree;
@@ -38,6 +39,7 @@ const PANE_RESIZE_STEP_PERCENT: u16 = 5;
const MAX_LOG_ENTRIES: u64 = 12;
const MAX_DIFF_PREVIEW_LINES: usize = 6;
const MAX_DIFF_PATCH_LINES: usize = 80;
const MAX_METRICS_GRAPH_RELATIONS: usize = 6;
const MAX_FILE_ACTIVITY_PATCH_LINES: usize = 3;
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -85,6 +87,7 @@ pub struct Dashboard {
notifier: DesktopNotifier,
webhook_notifier: WebhookNotifier,
sessions: Vec<Session>,
session_harnesses: HashMap<String, SessionHarnessInfo>,
session_output_cache: HashMap<String, Vec<OutputLine>>,
unread_message_counts: HashMap<String, usize>,
approval_queue_counts: HashMap<String, usize>,
@@ -473,6 +476,29 @@ impl SessionCompletionSummary {
}
}
fn load_session_harnesses(
db: &StateStore,
cfg: &Config,
sessions: &[Session],
) -> HashMap<String, SessionHarnessInfo> {
let working_dirs = sessions
.iter()
.map(|session| (session.id.as_str(), session.working_dir.as_path()))
.collect::<HashMap<_, _>>();
db.list_session_harnesses()
.unwrap_or_default()
.into_iter()
.map(|(session_id, info)| {
let info = if let Some(working_dir) = working_dirs.get(session_id.as_str()) {
info.with_config_detection(cfg, working_dir)
} else {
info
};
(session_id, info)
})
.collect()
}
impl Dashboard {
pub fn new(db: StateStore, cfg: Config) -> Self {
Self::with_output_store(db, cfg, SessionOutputStore::default())
@@ -495,6 +521,7 @@ impl Dashboard {
let _ = db.sync_tool_activity_metrics(&cfg.tool_activity_metrics_path());
}
let sessions = db.list_sessions().unwrap_or_default();
let session_harnesses = load_session_harnesses(&db, &cfg, &sessions);
let initial_session_states = sessions
.iter()
.map(|session| (session.id.clone(), session.state.clone()))
@@ -520,6 +547,7 @@ impl Dashboard {
notifier,
webhook_notifier,
sessions,
session_harnesses,
session_output_cache: HashMap::new(),
unread_message_counts: HashMap::new(),
approval_queue_counts: HashMap::new(),
@@ -843,7 +871,8 @@ impl Dashboard {
self.render_searchable_graph(&lines)
} else {
Text::from(
lines.into_iter()
lines
.into_iter()
.map(|line| Line::from(line.text))
.collect::<Vec<_>>(),
)
@@ -1227,7 +1256,7 @@ impl Dashboard {
self.theme_palette(),
)
})
.collect::<Vec<_>>(),
.collect::<Vec<_>>(),
)
}
@@ -2168,6 +2197,7 @@ impl Dashboard {
&comms::MessageType::TaskHandoff {
task: source_session.task.clone(),
context,
priority: comms::TaskPriority::Normal,
},
) {
tracing::warn!(
@@ -3295,7 +3325,10 @@ impl Dashboard {
return;
}
if !matches!(self.output_mode, OutputMode::SessionOutput | OutputMode::ContextGraph) {
if !matches!(
self.output_mode,
OutputMode::SessionOutput | OutputMode::ContextGraph
) {
self.set_operator_note(
"search is only available in session output or graph view".to_string(),
);
@@ -3646,6 +3679,7 @@ impl Dashboard {
&comms::MessageType::TaskHandoff {
task: task.clone(),
context: context.clone(),
priority: comms::TaskPriority::Normal,
},
) {
tracing::warn!(
@@ -4029,6 +4063,7 @@ impl Dashboard {
Vec::new()
}
};
self.session_harnesses = load_session_harnesses(&self.db, &self.cfg, &self.sessions);
self.unread_message_counts = match self.db.unread_message_counts() {
Ok(counts) => counts,
Err(error) => {
@@ -4148,6 +4183,11 @@ impl Dashboard {
}
SessionState::Completed => {
let summary = self.build_completion_summary(session);
self.persist_completion_summary_observation(
session,
&summary,
"completion_summary",
);
if self.cfg.completion_summary_notifications.enabled {
completion_summaries.push(summary.clone());
} else if self.cfg.desktop_notifications.session_completed {
@@ -4169,6 +4209,11 @@ impl Dashboard {
}
SessionState::Failed => {
let summary = self.build_completion_summary(session);
self.persist_completion_summary_observation(
session,
&summary,
"failure_summary",
);
failed_notifications.push((
"ECC 2.0: Session failed".to_string(),
format!(
@@ -4221,6 +4266,41 @@ impl Dashboard {
self.last_session_states = next_states;
}
fn persist_completion_summary_observation(
&self,
session: &Session,
summary: &SessionCompletionSummary,
observation_type: &str,
) {
let observation_summary = format!(
"{} | files {} | tests {}/{} | warnings {}",
truncate_for_dashboard(&summary.task, 72),
summary.files_changed,
summary.tests_passed,
summary.tests_run,
summary.warnings.len()
);
let details = completion_summary_observation_details(summary, session);
let priority = if observation_type == "failure_summary" {
ContextObservationPriority::High
} else {
ContextObservationPriority::Normal
};
if let Err(error) = self.db.add_session_observation(
&session.id,
observation_type,
priority,
false,
&observation_summary,
&details,
) {
tracing::warn!(
"Failed to persist completion observation for {}: {error}",
session.id
);
}
}
fn sync_approval_notifications(&mut self) {
let latest_message = match self.db.latest_unread_approval_message() {
Ok(message) => message,
@@ -4909,8 +4989,16 @@ impl Dashboard {
}
self.selected_team_summary = if team.total > 0 { Some(team) } else { None };
self.selected_route_preview =
self.build_route_preview(team.total, &route_candidates);
let selected_agent_type = self
.selected_agent_type()
.unwrap_or(self.cfg.default_agent.as_str())
.to_string();
self.selected_route_preview = self.build_route_preview(
&session_id,
&selected_agent_type,
team.total,
&route_candidates,
);
delegated.sort_by_key(|delegate| {
(
delegate_attention_priority(delegate),
@@ -4933,9 +5021,23 @@ impl Dashboard {
fn build_route_preview(
&self,
lead_id: &str,
lead_agent_type: &str,
delegate_count: usize,
delegates: &[DelegatedChildSummary],
) -> Option<String> {
if let Some(task) = self.latest_route_task(lead_id) {
if let Ok(preview) = manager::preview_assignment_for_task(
&self.db,
&self.cfg,
lead_id,
&task,
lead_agent_type,
) {
return Some(self.format_assignment_preview(&task, &preview));
}
}
if let Some(idle_clear) = delegates
.iter()
.filter(|delegate| {
@@ -4959,7 +5061,7 @@ impl Dashboard {
.min_by_key(|delegate| (delegate.handoff_backlog, delegate.session_id.as_str()))
{
return Some(format!(
"reuse idle {} with backlog {}",
"defer; idle {} backlog {}",
format_session_id(&idle_backed_up.session_id),
idle_backed_up.handoff_backlog
));
@@ -4976,9 +5078,18 @@ impl Dashboard {
.min_by_key(|delegate| (delegate.handoff_backlog, delegate.session_id.as_str()))
{
return Some(format!(
"reuse active {} with backlog {}",
"{} active {}{}",
if active_delegate.handoff_backlog > 0 {
"defer;"
} else {
"reuse"
},
format_session_id(&active_delegate.session_id),
active_delegate.handoff_backlog
if active_delegate.handoff_backlog > 0 {
format!(" backlog {}", active_delegate.handoff_backlog)
} else {
String::new()
}
));
}
@@ -4989,6 +5100,77 @@ impl Dashboard {
}
}
fn latest_route_task(&self, session_id: &str) -> Option<String> {
self.db
.list_messages_for_session(session_id, 16)
.ok()?
.into_iter()
.rev()
.find_map(|message| {
if message.to_session != session_id || message.msg_type != "task_handoff" {
return None;
}
manager::parse_task_handoff_task(&message.content).or_else(|| Some(message.content))
})
}
fn format_assignment_preview(
&self,
task: &str,
preview: &manager::AssignmentPreview,
) -> String {
let task_preview = truncate_for_dashboard(task, 40);
let graph_suffix = if preview.graph_match_terms.is_empty() {
String::new()
} else {
format!(
" | graph {}",
truncate_for_dashboard(&preview.graph_match_terms.join(", "), 36)
)
};
match preview.action {
manager::AssignmentAction::Spawned => {
format!("for `{task_preview}` spawn new delegate")
}
manager::AssignmentAction::ReusedIdle => format!(
"for `{task_preview}` reuse idle {}{}",
preview
.session_id
.as_deref()
.map(format_session_id)
.unwrap_or_else(|| "unknown".to_string()),
graph_suffix
),
manager::AssignmentAction::ReusedActive => format!(
"for `{task_preview}` reuse active {}{}",
preview
.session_id
.as_deref()
.map(format_session_id)
.unwrap_or_else(|| "unknown".to_string()),
graph_suffix
),
manager::AssignmentAction::DeferredSaturated => {
let state_label = match preview.delegate_state {
Some(SessionState::Idle) => "idle",
Some(SessionState::Running) | Some(SessionState::Pending) => "active",
_ => "delegate",
};
format!(
"for `{task_preview}` defer; {state_label} {} backlog {}{}",
preview
.session_id
.as_deref()
.map(format_session_id)
.unwrap_or_else(|| "unknown".to_string()),
preview.handoff_backlog,
graph_suffix
)
}
}
}
fn selected_session_id(&self) -> Option<&str> {
self.sessions
.get(self.selected_session)
@@ -5135,6 +5317,129 @@ impl Dashboard {
lines
}
fn session_graph_metrics_lines(&self, session_id: &str) -> Vec<String> {
let entity = self
.db
.list_context_entities(Some(session_id), Some("session"), 4)
.unwrap_or_default()
.into_iter()
.find(|entity| {
entity.session_id.as_deref() == Some(session_id) || entity.name == session_id
});
let Some(entity) = entity else {
return Vec::new();
};
let Ok(Some(detail)) = self
.db
.get_context_entity_detail(entity.id, MAX_METRICS_GRAPH_RELATIONS)
else {
return Vec::new();
};
if detail.outgoing.is_empty() && detail.incoming.is_empty() {
return Vec::new();
}
let mut lines = vec![
"Context graph".to_string(),
format!(
"- outgoing {} | incoming {}",
detail.outgoing.len(),
detail.incoming.len()
),
];
for relation in detail.outgoing.iter().take(4) {
lines.push(format!(
"- -> {} {}:{}",
relation.relation_type,
relation.to_entity_type,
truncate_for_dashboard(&relation.to_entity_name, 72)
));
}
for relation in detail.incoming.iter().take(2) {
lines.push(format!(
"- <- {} {}:{}",
relation.relation_type,
relation.from_entity_type,
truncate_for_dashboard(&relation.from_entity_name, 72)
));
}
lines
}
fn session_graph_recall_lines(&self, session: &Session) -> Vec<String> {
let query = session.task.trim();
if query.is_empty() {
return Vec::new();
}
let Ok(entries) = self.db.recall_context_entities(None, query, 4) else {
return Vec::new();
};
let entries = entries
.into_iter()
.filter(|entry| {
!(entry.entity.entity_type == "session" && entry.entity.name == session.id)
})
.take(3)
.collect::<Vec<_>>();
if entries.is_empty() {
return Vec::new();
}
let mut lines = vec!["Relevant memory".to_string()];
for entry in entries {
let mut line = format!(
"- #{} [{}] {} | score {} | relations {} | observations {} | priority {}",
entry.entity.id,
entry.entity.entity_type,
truncate_for_dashboard(&entry.entity.name, 60),
entry.score,
entry.relation_count,
entry.observation_count,
entry.max_observation_priority
);
if entry.has_pinned_observation {
line.push_str(" | pinned");
}
if let Some(session_id) = entry.entity.session_id.as_deref() {
if session_id != session.id {
line.push_str(&format!(" | {}", format_session_id(session_id)));
}
}
lines.push(line);
if !entry.matched_terms.is_empty() {
lines.push(format!(" matches {}", entry.matched_terms.join(", ")));
}
if let Some(path) = entry.entity.path.as_deref() {
lines.push(format!(" path {}", truncate_for_dashboard(path, 72)));
}
if !entry.entity.summary.is_empty() {
lines.push(format!(
" summary {}",
truncate_for_dashboard(&entry.entity.summary, 72)
));
}
if let Ok(observations) = self.db.list_context_observations(Some(entry.entity.id), 1) {
if let Some(observation) = observations.first() {
lines.push(format!(
" memory [{}{}] {}",
observation.priority,
if observation.pinned { "/pinned" } else { "" },
truncate_for_dashboard(&observation.summary, 72)
));
}
}
}
lines
}
fn visible_git_status_lines(&self) -> Vec<Line<'static>> {
self.selected_git_status_entries
.iter()
@@ -6056,6 +6361,14 @@ impl Dashboard {
}
}
if let Some(harness) = self.session_harnesses.get(&session.id) {
lines.push(format!(
"Harness {} | Detected {}",
harness.primary_label,
harness.detected_summary()
));
}
lines.push(format!(
"Tokens {} total | In {} | Out {}",
format_token_count(metrics.tokens_used),
@@ -6100,6 +6413,8 @@ impl Dashboard {
}
}
}
lines.extend(self.session_graph_recall_lines(session));
lines.extend(self.session_graph_metrics_lines(&session.id));
let file_overlaps = self
.db
.list_file_overlaps(&session.id, 3)
@@ -8300,6 +8615,39 @@ fn summarize_completion_warnings(
warnings
}
fn completion_summary_observation_details(
summary: &SessionCompletionSummary,
session: &Session,
) -> BTreeMap<String, String> {
let mut details = BTreeMap::new();
details.insert("state".to_string(), session.state.to_string());
details.insert(
"files_changed".to_string(),
summary.files_changed.to_string(),
);
details.insert("tokens_used".to_string(), summary.tokens_used.to_string());
details.insert(
"duration_secs".to_string(),
summary.duration_secs.to_string(),
);
details.insert("cost_usd".to_string(), format!("{:.4}", summary.cost_usd));
details.insert("tests_run".to_string(), summary.tests_run.to_string());
details.insert("tests_passed".to_string(), summary.tests_passed.to_string());
if !summary.recent_files.is_empty() {
details.insert("recent_files".to_string(), summary.recent_files.join(" | "));
}
if !summary.key_decisions.is_empty() {
details.insert(
"key_decisions".to_string(),
summary.key_decisions.join(" | "),
);
}
if !summary.warnings.is_empty() {
details.insert("warnings".to_string(), summary.warnings.join(" | "));
}
details
}
fn session_started_webhook_body(session: &Session, compare_url: Option<&str>) -> String {
let mut lines = vec![
"*ECC 2.0: Session started*".to_string(),
@@ -10058,8 +10406,12 @@ diff --git a/src/lib.rs b/src/lib.rs\n\
let mut dashboard = test_dashboard(vec![focus.clone(), review.clone()], 0);
dashboard.db.insert_session(&focus)?;
dashboard.db.insert_session(&review)?;
dashboard.db.insert_decision(&focus.id, "Alpha graph path", &[], "planner path")?;
dashboard.db.insert_decision(&review.id, "Beta graph path", &[], "review path")?;
dashboard
.db
.insert_decision(&focus.id, "Alpha graph path", &[], "planner path")?;
dashboard
.db
.insert_decision(&review.id, "Beta graph path", &[], "review path")?;
dashboard.toggle_context_graph_mode();
dashboard.toggle_search_scope();
@@ -10099,8 +10451,12 @@ diff --git a/src/lib.rs b/src/lib.rs\n\
let mut dashboard = test_dashboard(vec![focus.clone(), review.clone()], 0);
dashboard.db.insert_session(&focus)?;
dashboard.db.insert_session(&review)?;
dashboard.db.insert_decision(&focus.id, "alpha local graph", &[], "planner path")?;
dashboard.db.insert_decision(&review.id, "alpha remote graph", &[], "review path")?;
dashboard
.db
.insert_decision(&focus.id, "alpha local graph", &[], "planner path")?;
dashboard
.db
.insert_decision(&review.id, "alpha remote graph", &[], "review path")?;
dashboard.toggle_context_graph_mode();
dashboard.toggle_search_scope();
@@ -10119,7 +10475,10 @@ diff --git a/src/lib.rs b/src/lib.rs\n\
dashboard.operator_note.as_deref(),
Some("graph search /alpha.* match 2/2 | all sessions")
);
assert_ne!(dashboard.selected_session_id().map(str::to_string), first_session);
assert_ne!(
dashboard.selected_session_id().map(str::to_string),
first_session
);
Ok(())
}
@@ -10157,6 +10516,92 @@ diff --git a/src/lib.rs b/src/lib.rs\n\
Ok(())
}
#[test]
fn selected_session_metrics_text_includes_context_graph_relations() -> Result<()> {
let focus = sample_session(
"focus-12345678",
"planner",
SessionState::Running,
None,
1,
1,
);
let delegate = sample_session("delegate-87654321", "coder", SessionState::Idle, None, 1, 1);
let dashboard = test_dashboard(vec![focus.clone(), delegate.clone()], 0);
dashboard.db.insert_session(&focus)?;
dashboard.db.insert_session(&delegate)?;
dashboard.db.insert_decision(
&focus.id,
"Use sqlite graph sync",
&[],
"Keeps shared memory queryable",
)?;
dashboard.db.send_message(
&focus.id,
&delegate.id,
"{\"task\":\"Review graph edge\",\"context\":\"coordination smoke\"}",
"task_handoff",
)?;
let text = dashboard.selected_session_metrics_text();
assert!(text.contains("Context graph"));
assert!(text.contains("outgoing 2 | incoming 0"));
assert!(text.contains("-> decided decision:Use sqlite graph sync"));
assert!(text.contains("-> delegates_to session:delegate-87654321"));
Ok(())
}
#[test]
fn selected_session_metrics_text_includes_relevant_memory() -> Result<()> {
let mut focus = sample_session(
"focus-12345678",
"planner",
SessionState::Running,
None,
1,
1,
);
focus.task = "Investigate auth callback recovery".to_string();
let mut memory = sample_session("memory-87654321", "coder", SessionState::Idle, None, 1, 1);
memory.task = "Auth callback recovery notes".to_string();
let dashboard = test_dashboard(vec![focus.clone(), memory.clone()], 0);
dashboard.db.insert_session(&focus)?;
dashboard.db.insert_session(&memory)?;
dashboard.db.upsert_context_entity(
Some(&memory.id),
"file",
"callback.ts",
Some("src/routes/auth/callback.ts"),
"Handles auth callback recovery and billing fallback",
&BTreeMap::from([("area".to_string(), "auth".to_string())]),
)?;
let entity = dashboard
.db
.list_context_entities(Some(&memory.id), Some("file"), 10)?
.into_iter()
.find(|entry| entry.name == "callback.ts")
.expect("callback entity");
dashboard.db.add_context_observation(
Some(&memory.id),
entity.id,
"completion_summary",
ContextObservationPriority::Normal,
true,
"Recovered auth callback incident with billing fallback",
&BTreeMap::new(),
)?;
let text = dashboard.selected_session_metrics_text();
assert!(text.contains("Relevant memory"));
assert!(text.contains("[file] callback.ts"));
assert!(text.contains("| pinned"));
assert!(text.contains("matches auth, callback, recovery"));
assert!(text.contains(
"memory [normal/pinned] Recovered auth callback incident with billing fallback"
));
Ok(())
}
#[test]
fn worktree_diff_columns_split_removed_and_added_lines() {
let patch = "\
@@ -10954,6 +11399,91 @@ diff --git a/src/lib.rs b/src/lib.rs
assert!(!text.contains("Backlog focus-12"));
}
#[test]
fn route_preview_uses_graph_context_for_latest_incoming_handoff() {
let lead = sample_session(
"lead-12345678",
"planner",
SessionState::Running,
Some("ecc/lead"),
512,
42,
);
let older_worker = sample_session(
"older-worker",
"planner",
SessionState::Idle,
Some("ecc/older"),
128,
12,
);
let auth_worker = sample_session(
"auth-worker",
"planner",
SessionState::Idle,
Some("ecc/auth"),
256,
24,
);
let mut dashboard = test_dashboard(
vec![lead.clone(), older_worker.clone(), auth_worker.clone()],
0,
);
dashboard.db.insert_session(&lead).unwrap();
dashboard.db.insert_session(&older_worker).unwrap();
dashboard.db.insert_session(&auth_worker).unwrap();
dashboard
.db
.send_message(
"lead-12345678",
"older-worker",
"{\"task\":\"Legacy delegated work\",\"context\":\"Delegated from lead\"}",
"task_handoff",
)
.unwrap();
dashboard
.db
.send_message(
"lead-12345678",
"auth-worker",
"{\"task\":\"Auth delegated work\",\"context\":\"Delegated from lead\"}",
"task_handoff",
)
.unwrap();
dashboard.db.mark_messages_read("older-worker").unwrap();
dashboard.db.mark_messages_read("auth-worker").unwrap();
dashboard
.db
.send_message(
"planner-root",
"lead-12345678",
"{\"task\":\"Investigate auth callback recovery\",\"context\":\"Delegated from planner-root\"}",
"task_handoff",
)
.unwrap();
dashboard
.db
.upsert_context_entity(
Some("auth-worker"),
"file",
"auth-callback.ts",
Some("src/auth/callback.ts"),
"Auth callback recovery edge cases",
&BTreeMap::new(),
)
.unwrap();
dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap();
dashboard.sync_selected_messages();
dashboard.sync_selected_lineage();
assert_eq!(
dashboard.selected_route_preview.as_deref(),
Some("for `Investigate auth callback recovery` reuse idle auth-wor | graph auth, callback, recovery")
);
}
#[test]
fn route_preview_ignores_non_handoff_inbox_noise() {
let lead = sample_session(
@@ -11496,6 +12026,73 @@ diff --git a/src/lib.rs b/src/lib.rs
Ok(())
}
#[test]
fn refresh_persists_completion_summary_observation() -> Result<()> {
let root =
std::env::temp_dir().join(format!("ecc2-completion-observation-{}", Uuid::new_v4()));
fs::create_dir_all(root.join(".claude").join("metrics"))?;
let mut cfg = build_config(&root.join(".claude"));
cfg.completion_summary_notifications.delivery =
crate::notifications::CompletionSummaryDelivery::TuiPopup;
cfg.desktop_notifications.session_completed = false;
let db = StateStore::open(&cfg.db_path)?;
let mut session = sample_session(
"done-observation",
"claude",
SessionState::Running,
Some("ecc/observation"),
144,
42,
);
session.task = "Recover auth callback after wipe".to_string();
db.insert_session(&session)?;
let metrics_path = cfg.tool_activity_metrics_path();
fs::create_dir_all(metrics_path.parent().unwrap())?;
fs::write(
&metrics_path,
concat!(
"{\"id\":\"evt-1\",\"session_id\":\"done-observation\",\"tool_name\":\"Bash\",\"input_summary\":\"cargo test -q\",\"input_params_json\":\"{\\\"command\\\":\\\"cargo test -q\\\"}\",\"output_summary\":\"ok\",\"timestamp\":\"2026-04-09T00:00:00Z\"}\n",
"{\"id\":\"evt-2\",\"session_id\":\"done-observation\",\"tool_name\":\"Write\",\"input_summary\":\"Write src/routes/auth/callback.ts\",\"output_summary\":\"updated callback\",\"file_events\":[{\"path\":\"src/routes/auth/callback.ts\",\"action\":\"modify\",\"diff_preview\":\"portal first\",\"patch_preview\":\"+ portal first\"}],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n"
),
)?;
let mut dashboard = Dashboard::new(db, cfg);
dashboard
.db
.update_state("done-observation", &SessionState::Completed)?;
dashboard.refresh();
let session_entity = dashboard
.db
.list_context_entities(Some("done-observation"), Some("session"), 10)?
.into_iter()
.find(|entity| entity.name == "done-observation")
.expect("session entity");
let observations = dashboard
.db
.list_context_observations(Some(session_entity.id), 10)?;
assert!(!observations.is_empty());
assert_eq!(observations[0].observation_type, "completion_summary");
assert!(observations[0]
.summary
.contains("Recover auth callback after wipe"));
assert_eq!(
observations[0].details.get("tests_run"),
Some(&"1".to_string())
);
assert!(observations[0]
.details
.get("recent_files")
.is_some_and(|value| value.contains("modify src/routes/auth/callback.ts")));
let _ = fs::remove_dir_all(root);
Ok(())
}
#[test]
fn dismiss_completion_popup_promotes_the_next_summary() {
let mut dashboard = test_dashboard(Vec::new(), 0);
@@ -11721,6 +12318,40 @@ diff --git a/src/lib.rs b/src/lib.rs
Ok(())
}
#[test]
fn selected_session_metrics_text_includes_harness_summary() -> Result<()> {
let tempdir = std::env::temp_dir().join(format!(
"ecc2-dashboard-harness-metrics-{}",
uuid::Uuid::new_v4()
));
fs::create_dir_all(tempdir.join(".claude"))?;
fs::create_dir_all(tempdir.join(".codex"))?;
let now = Utc::now();
let session = Session {
id: "sess-harness".to_string(),
task: "Map harness metadata".to_string(),
project: "ecc".to_string(),
task_group: "compat".to_string(),
agent_type: "claude".to_string(),
working_dir: tempdir.clone(),
state: SessionState::Running,
pid: Some(4242),
worktree: None,
created_at: now - Duration::minutes(3),
updated_at: now - Duration::minutes(1),
last_heartbeat_at: now - Duration::minutes(1),
metrics: SessionMetrics::default(),
};
let dashboard = test_dashboard(vec![session], 0);
let metrics_text = dashboard.selected_session_metrics_text();
assert!(metrics_text.contains("Harness claude | Detected claude, codex"));
let _ = fs::remove_dir_all(tempdir);
Ok(())
}
#[test]
fn new_session_task_uses_selected_session_context() {
let dashboard = test_dashboard(
@@ -13869,6 +14500,16 @@ diff --git a/src/lib.rs b/src/lib.rs
.iter()
.map(|session| (session.id.clone(), session.state.clone()))
.collect();
let session_harnesses = sessions
.iter()
.map(|session| {
(
session.id.clone(),
SessionHarnessInfo::detect(&session.agent_type, &session.working_dir)
.with_config_detection(&cfg, &session.working_dir),
)
})
.collect();
let output_store = SessionOutputStore::default();
let output_rx = output_store.subscribe();
let mut session_table_state = TableState::default();
@@ -13885,6 +14526,7 @@ diff --git a/src/lib.rs b/src/lib.rs
notifier,
webhook_notifier,
sessions,
session_harnesses,
session_output_cache: HashMap::new(),
unread_message_counts: HashMap::new(),
approval_queue_counts: HashMap::new(),
@@ -13966,8 +14608,11 @@ diff --git a/src/lib.rs b/src/lib.rs
auto_terminate_stale_sessions: false,
default_agent: "claude".to_string(),
default_agent_profile: None,
harness_runners: Default::default(),
agent_profiles: Default::default(),
orchestration_templates: Default::default(),
memory_connectors: Default::default(),
computer_use_dispatch: crate::config::ComputerUseDispatchConfig::default(),
auto_dispatch_unread_handoffs: false,
auto_dispatch_limit_per_session: 5,
auto_create_worktrees: true,