huskies: merge 1038

This commit is contained in:
dave
2026-05-14 17:00:33 +00:00
parent 4553df5b21
commit 1f9f34ab58
13 changed files with 940 additions and 2 deletions
+149
View File
@@ -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,