Crate renames: - zesdex-entities::seaorm → domain (misleading name, no SeaORM used) - zesdex-dto → merged into zesdex-entities (100% re-exports) - zesdex-libs → zesdex-infra (vague name) Module renames: - app/harness → guard (misleading: safety gatekeeper, not test harness) - runtime/commands → action_dispatch (name clashed with controller/command) - resources → prompts (embedded prompt text, not general resources) - tool/seqthink → sequential_think (unreadable abbreviation) - msglog/query → insert (module only inserts, never queries) Dead code removal: - app/mode/help.rs (orphaned — not declared in mod.rs) - app/mode/loading.rs (orphaned — not declared in mod.rs) File splitting (71 new files, avg ~115 lines/file): - app/runtime/actions/: 1→8 files (was 2030 lines) - view/overlays/: 1→16 files (was 1167 lines) - tool/lsp/: 1→8 per-tool files (was 909 lines) - main.rs: 1→5 files (session, daemon, attach, event_loop) - workflow/engine + hive_mind: 2→10 files - subagent/engine + auto: 2→9 files - lsp/provisioner: 1→5 files - review/: 1→6 files - guard/: 1→2 files (extracted patterns) - state/misc: 1→3 files (input, scroll) - mcp/: 1→3 files (transport, adapter) - stream/json_repair extracted from turn.rs DRY: - Pattern constants (STUB_PATTERNS etc) in guard/patterns shared with subagent - 3 near-identical background spawners → 1 generic + thin wrappers - Shared spawn_subagent_with_drain() extracted - Shared create_session() in main - write_osc52 deduplicated Bug fixes: - archive_message(): sess.db → db (wrong variable name) - execute_one_tool(): wrong parameter name - check_credential_read() function was missing (restored from test expectations)
367 lines
15 KiB
Rust
367 lines
15 KiB
Rust
//! Tick-action handler: drain turn events, LSP provision messages,
|
|
//! API connectivity checks, staleness sweep, pending lessons, and
|
|
//! todo.md polling.
|
|
|
|
use crate::app::review::{should_trigger_review, trigger_review};
|
|
use crate::app::state::rest::AppStateRest;
|
|
use crate::app::state::runtime::TurnEvent;
|
|
use crate::app::state::types::{Toast, ToastKind};
|
|
use crate::dto::chat::message::{ChatMessage, Role};
|
|
|
|
use super::io::{maybe_trigger_review, spawn_api_connectivity_check};
|
|
use super::memory::refresh_lesson_counters;
|
|
use super::turn::HIVE_MIND_KICKOFF_NOTE;
|
|
|
|
/// Handle `Action::Tick` — the periodic event that drains async results
|
|
/// and runs background maintenance tasks.
|
|
pub(super) fn handle_tick(state: &mut AppStateRest) {
|
|
state.misc.tick_count = state.misc.tick_count.wrapping_add(1);
|
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
|
state.misc.drain_expired_toasts(now_ms);
|
|
|
|
if state.misc.tick_count.is_multiple_of(10) {
|
|
let todo_path = state.session_dir.join("todo.md");
|
|
if let Ok(content) = std::fs::read_to_string(&todo_path) {
|
|
if content != state.misc.todo_content {
|
|
state.misc.todo_content = content;
|
|
state.dirty = true;
|
|
}
|
|
} else if !state.misc.todo_content.is_empty() {
|
|
state.misc.todo_content.clear();
|
|
state.dirty = true;
|
|
}
|
|
}
|
|
|
|
// Background API connectivity check — runs on a background thread
|
|
// every ~1s while disconnected, every ~30s while connected, so the
|
|
// status bar reflects real API availability without user input.
|
|
let check_interval = if state.misc.api_connected { 600 } else { 20 };
|
|
if state.misc.tick_count.is_multiple_of(check_interval) {
|
|
spawn_api_connectivity_check(state);
|
|
}
|
|
|
|
crate::app::review::maybe_run_staleness_sweep(state);
|
|
if let Some(ref rt) = state.session_runtime {
|
|
let _ = crate::app::review::process_pending_lessons(
|
|
&rt.session_dir,
|
|
&state.memory_dir,
|
|
);
|
|
}
|
|
|
|
// Drain LSP provision progress messages into toast notifications.
|
|
// Collect messages under the lock, then push toasts outside it to avoid
|
|
// a borrow-conflict with state.push_toast (which also accesses state).
|
|
let pending: Vec<String> = state
|
|
.lsp_provision_msgs
|
|
.lock()
|
|
.ok()
|
|
.map(|mut q| q.drain(..).collect())
|
|
.unwrap_or_default();
|
|
for msg in &pending {
|
|
let kind = if msg.contains("not available") || msg.contains("failed") {
|
|
ToastKind::Warning
|
|
} else if msg.contains("connected") || msg.contains("✓") {
|
|
ToastKind::Success
|
|
} else {
|
|
ToastKind::Info
|
|
};
|
|
state.push_toast(Toast::new(kind, msg.clone()));
|
|
}
|
|
|
|
let events: Vec<TurnEvent> = {
|
|
if let Ok(mut q) = state.turn_events.lock() {
|
|
q.drain(..).collect()
|
|
} else {
|
|
Vec::new()
|
|
}
|
|
};
|
|
let mut turn_finished = false;
|
|
for event in events {
|
|
match event {
|
|
TurnEvent::AssistantMessage(msg) => {
|
|
state.misc.thinking = false;
|
|
state.misc.api_connected = true;
|
|
let display_content = msg.content.clone().unwrap_or_default();
|
|
if !display_content.is_empty() {
|
|
state.push_transcript(
|
|
crate::app::state::rest::ChatMessageDisplay::new(
|
|
Role::Assistant,
|
|
display_content,
|
|
),
|
|
);
|
|
}
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
rt.push_message(msg);
|
|
}
|
|
}
|
|
TurnEvent::ToolResult {
|
|
tool_call_id,
|
|
tool_name,
|
|
output,
|
|
is_error,
|
|
path,
|
|
} => {
|
|
state.misc.thinking = false;
|
|
let display_path = path.unwrap_or_default();
|
|
let display = if tool_name == "read" {
|
|
let line_count = output.lines().count();
|
|
if display_path.is_empty() {
|
|
format!("read: {line_count} line(s)")
|
|
} else {
|
|
format!("read: {display_path} ({line_count} lines)")
|
|
}
|
|
} else {
|
|
format!("{tool_name}: {output}")
|
|
};
|
|
state.push_transcript(
|
|
crate::app::state::rest::ChatMessageDisplay::new(Role::Tool, display),
|
|
);
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
rt.push_message(ChatMessage::tool_result(
|
|
tool_call_id.clone(),
|
|
output.clone(),
|
|
));
|
|
rt.tool_call_results
|
|
.push(crate::app::state::runtime::ToolCallResult {
|
|
tool_call_id,
|
|
tool_name,
|
|
output,
|
|
is_error,
|
|
duration_ms: 0,
|
|
});
|
|
}
|
|
}
|
|
TurnEvent::SystemNote { kind, message } => {
|
|
if kind == "edits" {
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
if let Ok(count) = message.parse::<u32>() {
|
|
rt.edit_count += count;
|
|
}
|
|
}
|
|
if should_trigger_review(state, crate::app::state::types::Origin::Main) {
|
|
trigger_review(state);
|
|
}
|
|
} else if kind == "review" {
|
|
state.misc.lesson_running = false;
|
|
let counted = if let Some(ref mut rt) = state.session_runtime {
|
|
refresh_lesson_counters(&state.memory_dir, rt);
|
|
true
|
|
} else {
|
|
false
|
|
};
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
if counted {
|
|
rt.consecutive_empty_reviews = 0;
|
|
} else {
|
|
rt.consecutive_empty_reviews += 1;
|
|
}
|
|
}
|
|
state.push_toast(Toast::new(ToastKind::Info, message));
|
|
} else if kind == "task_retry" {
|
|
state.push_transcript(
|
|
crate::app::state::rest::ChatMessageDisplay::new(
|
|
Role::System,
|
|
message.clone(),
|
|
),
|
|
);
|
|
state.push_toast(Toast::new(
|
|
ToastKind::Info,
|
|
"Auto-continuing unfinished tasks...".to_string(),
|
|
));
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
rt.push_message(ChatMessage::system(message.clone()));
|
|
}
|
|
} else if kind == "connectivity" {
|
|
state.misc.api_connected = message == "connected";
|
|
} else if kind == "hive_mind_converged" {
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
rt.hive_mind_converged = true;
|
|
}
|
|
} else if kind == "pipeline" {
|
|
// Clear old workflow agents when a new pipeline starts.
|
|
if message == HIVE_MIND_KICKOFF_NOTE {
|
|
state.workflow_engine.agents.clear();
|
|
state.workflow_engine.findings.clear();
|
|
}
|
|
// popup removed, no overlay to reset
|
|
state.push_toast(Toast {
|
|
kind: ToastKind::Info,
|
|
message: message.clone(),
|
|
created_at: chrono::Utc::now().timestamp_millis(),
|
|
lifetime_ms: 12000,
|
|
});
|
|
state.dirty = true;
|
|
} else if kind == "bg-test-gen" {
|
|
let escalated = message.starts_with("ESCALATED:");
|
|
state.push_toast(Toast {
|
|
kind: if escalated {
|
|
ToastKind::Error
|
|
} else {
|
|
ToastKind::Info
|
|
},
|
|
message: message.clone(),
|
|
created_at: chrono::Utc::now().timestamp_millis(),
|
|
lifetime_ms: if escalated { 30000 } else { 8000 },
|
|
});
|
|
state.dirty = true;
|
|
} else if kind == "bg-arch-review" || kind == "bg-security-review" {
|
|
let escalated = message.starts_with("ESCALATED:");
|
|
state.push_toast(Toast {
|
|
kind: if escalated {
|
|
ToastKind::Error
|
|
} else {
|
|
ToastKind::Info
|
|
},
|
|
message: message.clone(),
|
|
created_at: chrono::Utc::now().timestamp_millis(),
|
|
lifetime_ms: if escalated { 30000 } else { 10000 },
|
|
});
|
|
state.dirty = true;
|
|
} else if kind == "workflow_done" {
|
|
state.push_toast(Toast {
|
|
kind: ToastKind::Success,
|
|
message: message.clone(),
|
|
created_at: chrono::Utc::now().timestamp_millis(),
|
|
lifetime_ms: 10000,
|
|
});
|
|
state.push_transcript(
|
|
crate::app::state::rest::ChatMessageDisplay::new(
|
|
Role::System,
|
|
format!("✓ {message}"),
|
|
),
|
|
);
|
|
// overlay removed
|
|
state.dirty = true;
|
|
} else if kind == "workflow_error" {
|
|
state.push_toast(Toast {
|
|
kind: ToastKind::Error,
|
|
message: message.clone(),
|
|
created_at: chrono::Utc::now().timestamp_millis(),
|
|
lifetime_ms: 12000,
|
|
});
|
|
state.push_transcript(
|
|
crate::app::state::rest::ChatMessageDisplay::new(
|
|
Role::System,
|
|
format!("✗ {message}"),
|
|
),
|
|
);
|
|
// overlay removed
|
|
state.dirty = true;
|
|
} else {
|
|
state.push_toast(Toast::new(ToastKind::Info, message));
|
|
}
|
|
}
|
|
TurnEvent::StreamStart => {
|
|
state.misc.thinking = false;
|
|
state.misc.api_connected = true;
|
|
state.push_transcript(
|
|
crate::app::state::rest::ChatMessageDisplay::new(
|
|
Role::Assistant,
|
|
String::new(),
|
|
),
|
|
);
|
|
}
|
|
TurnEvent::StreamToken(delta) => {
|
|
if let Some(last) = state.transcript_cache.messages.last_mut() {
|
|
if last.role == Role::Assistant {
|
|
last.content.push_str(&delta);
|
|
state.transcript_cache.dirty = true;
|
|
}
|
|
}
|
|
}
|
|
TurnEvent::StreamDone(msg) => {
|
|
state.misc.thinking = false;
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
rt.push_message(msg);
|
|
}
|
|
}
|
|
TurnEvent::Usage {
|
|
tokens_in,
|
|
tokens_out,
|
|
} => {
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
rt.usage.tokens_in += tokens_in;
|
|
rt.usage.tokens_out += tokens_out;
|
|
rt.usage.last_tokens_in = tokens_in;
|
|
rt.usage.last_tokens_out = tokens_out;
|
|
rt.usage.api_calls += 1;
|
|
}
|
|
}
|
|
TurnEvent::ReviewUsage {
|
|
tokens_in,
|
|
tokens_out,
|
|
} => {
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
rt.usage.tokens_in += tokens_in;
|
|
rt.usage.tokens_out += tokens_out;
|
|
rt.usage.review_tokens += tokens_in + tokens_out;
|
|
rt.usage.api_calls += 1;
|
|
}
|
|
}
|
|
TurnEvent::Error(msg) => {
|
|
state.misc.api_connected = false;
|
|
let long_toast = Toast {
|
|
kind: ToastKind::Error,
|
|
message: msg.clone(),
|
|
created_at: chrono::Utc::now().timestamp_millis(),
|
|
lifetime_ms: 15000,
|
|
};
|
|
state.push_toast(long_toast);
|
|
state.push_transcript(
|
|
crate::app::state::rest::ChatMessageDisplay::new(
|
|
Role::System,
|
|
format!("Error: {msg}"),
|
|
),
|
|
);
|
|
turn_finished = true;
|
|
}
|
|
TurnEvent::Done => {
|
|
state.misc.thinking = false;
|
|
turn_finished = true;
|
|
}
|
|
TurnEvent::Compacted(new_msgs) => {
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
rt.messages = new_msgs;
|
|
state.push_toast(Toast::new(
|
|
ToastKind::Info,
|
|
"History auto-compacted by AI.".to_string(),
|
|
));
|
|
state.dirty = true;
|
|
}
|
|
}
|
|
TurnEvent::WorkflowAgentUpdate {
|
|
agent_id,
|
|
agent_name,
|
|
status,
|
|
} => {
|
|
// Upsert the agent in the workflow engine roster.
|
|
// Running agents are pushed as new entries; status
|
|
// updates find the existing entry by id and replace it.
|
|
use crate::app::workflow::engine::WorkflowAgent;
|
|
if let Some(existing) = state
|
|
.workflow_engine
|
|
.agents
|
|
.iter_mut()
|
|
.find(|a| a.id == agent_id)
|
|
{
|
|
existing.status = status;
|
|
} else {
|
|
state.workflow_engine.agents.push(WorkflowAgent {
|
|
id: agent_id,
|
|
name: agent_name,
|
|
status,
|
|
});
|
|
}
|
|
// popup removed
|
|
state.dirty = true;
|
|
}
|
|
}
|
|
}
|
|
if turn_finished {
|
|
maybe_trigger_review(state);
|
|
}
|
|
if turn_finished || state.dirty {
|
|
state.dirty = true;
|
|
}
|
|
}
|