huskies: merge 1038
This commit is contained in:
@@ -8,6 +8,8 @@ use crate::http::context::AppContext;
|
||||
use crate::http::mcp::dispatch::dispatch_tool_call;
|
||||
use crate::io;
|
||||
use crate::service;
|
||||
use crate::service::event_triggers::store::EventTriggerStore;
|
||||
use crate::service::event_triggers::{FireMode, TriggerAction};
|
||||
use crate::service::status::StatusBroadcaster;
|
||||
use crate::service::timer::scheduled::{
|
||||
ScheduledTimer, ScheduledTimerStore, TimerAction, TimerMode,
|
||||
@@ -304,6 +306,153 @@ pub(crate) fn spawn_gateway_relay(startup_root: &Option<PathBuf>, status: Arc<St
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the event-trigger subscriber.
|
||||
///
|
||||
/// Subscribes to [`crate::pipeline_state::subscribe_transitions`] and on each
|
||||
/// [`crate::pipeline_state::TransitionFired`] checks every registered trigger's
|
||||
/// predicate. Matching triggers have their action executed:
|
||||
///
|
||||
/// - `Mcp`: dispatches the named MCP tool via the full `dispatch_tool_call` path.
|
||||
/// - `Prompt`: creates an ephemeral story and starts an agent on it.
|
||||
///
|
||||
/// `Once` triggers are removed from the store after they fire; `Persistent`
|
||||
/// triggers remain until explicitly cancelled via `cancel_event_trigger`.
|
||||
pub(crate) fn spawn_event_trigger_subscriber(
|
||||
store: Arc<EventTriggerStore>,
|
||||
agents: Arc<AgentPool>,
|
||||
project_root: Option<PathBuf>,
|
||||
ctx: AppContext,
|
||||
) {
|
||||
let mut rx = crate::pipeline_state::subscribe_transitions();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let fired = match rx.recv().await {
|
||||
Ok(f) => f,
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
crate::slog!(
|
||||
"[event-triggers] Lagged {n} transition events; some triggers may have been skipped"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
crate::slog!("[event-triggers] Transition channel closed; subscriber stopping");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let triggers = store.list();
|
||||
if triggers.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut to_cancel: Vec<String> = Vec::new();
|
||||
|
||||
for trigger in &triggers {
|
||||
if !trigger.predicate.matches(&fired) {
|
||||
continue;
|
||||
}
|
||||
|
||||
crate::slog!(
|
||||
"[event-triggers] Trigger {} matched: story={} {}→{}",
|
||||
trigger.id,
|
||||
fired.story_id.0,
|
||||
crate::pipeline_state::stage_label(&fired.before),
|
||||
crate::pipeline_state::stage_label(&fired.after),
|
||||
);
|
||||
|
||||
match &trigger.action {
|
||||
TriggerAction::Mcp { method, args } => {
|
||||
execute_mcp_action(method, args.clone(), &ctx).await;
|
||||
}
|
||||
TriggerAction::Prompt { text } => {
|
||||
execute_prompt_action(
|
||||
text,
|
||||
&fired.story_id.0,
|
||||
&agents,
|
||||
project_root.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
if trigger.mode == FireMode::Once {
|
||||
to_cancel.push(trigger.id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if !to_cancel.is_empty() {
|
||||
store.cancel_batch(&to_cancel);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Execute an Mcp action by dispatching through the full MCP tool dispatch path.
|
||||
async fn execute_mcp_action(method: &str, args: serde_json::Value, ctx: &AppContext) {
|
||||
match crate::http::mcp::dispatch::dispatch_tool_call(method, args, ctx).await {
|
||||
Ok(result) => {
|
||||
crate::slog!("[event-triggers] Mcp '{method}' succeeded: {result}");
|
||||
}
|
||||
Err(e) => {
|
||||
crate::slog!("[event-triggers] Mcp '{method}' failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a Prompt action: create an ephemeral story and start an agent on it.
|
||||
async fn execute_prompt_action(
|
||||
text: &str,
|
||||
triggering_story_id: &str,
|
||||
agents: &Arc<AgentPool>,
|
||||
project_root: Option<&std::path::Path>,
|
||||
) {
|
||||
let Some(root) = project_root else {
|
||||
crate::slog!("[event-triggers] Prompt action skipped (no project root configured): {text}");
|
||||
return;
|
||||
};
|
||||
|
||||
// Allocate a new story ID for the ephemeral agent task.
|
||||
let num = crate::db::ops::next_item_number();
|
||||
let story_id = format!("{num}_trigger_task");
|
||||
|
||||
let content = format!(
|
||||
"---\nname: Trigger Task\n---\n\
|
||||
# Trigger Task\n\n\
|
||||
_Auto-created by event trigger (source story: {triggering_story_id})_\n\n\
|
||||
## Task\n\n\
|
||||
{text}\n\n\
|
||||
## Acceptance Criteria\n\n\
|
||||
- [ ] Complete the task described above and exit.\n"
|
||||
);
|
||||
|
||||
crate::db::write_item_with_content(
|
||||
&story_id,
|
||||
"1_backlog",
|
||||
&content,
|
||||
crate::db::ItemMeta {
|
||||
name: Some("Trigger Task".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
if let Err(e) = crate::agents::lifecycle::move_story_to_current(&story_id) {
|
||||
crate::slog!("[event-triggers] Failed to move {story_id} to current: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
match agents.start_agent(root, &story_id, None, None, None).await {
|
||||
Ok(info) => {
|
||||
crate::slog!(
|
||||
"[event-triggers] Started agent {} for prompt task {story_id}",
|
||||
info.agent_name
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
crate::slog!("[event-triggers] Failed to start agent for prompt task {story_id}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the startup reconstruction task: replay the current pipeline state
|
||||
/// through the [`TransitionFired`][crate::pipeline_state::TransitionFired]
|
||||
/// broadcast channel so that all existing subscribers (worktree lifecycle,
|
||||
|
||||
Reference in New Issue
Block a user