diff --git a/server/src/service/event_triggers/store.rs b/server/src/service/event_triggers/store.rs index 3ea30c3f..7b8c2c13 100644 --- a/server/src/service/event_triggers/store.rs +++ b/server/src/service/event_triggers/store.rs @@ -533,4 +533,48 @@ mod tests { let args = serde_json::json!({ "mode": "persistent" }); assert_eq!(parse_mode(&args), FireMode::Persistent); } + + /// Regression test: once-mode triggers with a server-restarting action (e.g. + /// rebuild_and_restart) must be removed from the store BEFORE the action is + /// dispatched. If cancellation happens after dispatch, a server restart + /// caused by the action reloads the persisted store and replays the trigger. + #[test] + fn once_mode_rebuild_trigger_cancelled_before_action() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("triggers.json"); + let store = EventTriggerStore::load(path.clone()); + + let trigger = store + .add( + TriggerPredicate { + story_id: None, + from_stage: None, + to_stage: Some("Current".to_string()), + event_kind: None, + }, + TriggerAction::Mcp { + method: "rebuild_and_restart".to_string(), + args: serde_json::json!({}), + }, + FireMode::Once, + ) + .unwrap(); + let id = trigger.id.clone(); + + // Simulate the subscriber: cancel BEFORE the action runs. + store.cancel(&id); + + // Trigger must be absent immediately (simulates "right after match"). + assert!( + store.list().is_empty(), + "once-mode trigger must be absent before its action fires" + ); + + // Simulate server restart: reload from persisted store. + let reloaded = EventTriggerStore::load(path); + assert!( + reloaded.list().is_empty(), + "trigger must not survive a server restart caused by its own action" + ); + } } diff --git a/server/src/startup/tick_loop.rs b/server/src/startup/tick_loop.rs index a2c2f19c..0fc6fa6e 100644 --- a/server/src/startup/tick_loop.rs +++ b/server/src/startup/tick_loop.rs @@ -346,8 +346,6 @@ pub(crate) fn spawn_event_trigger_subscriber( continue; } - let mut to_cancel: Vec = Vec::new(); - for trigger in &triggers { if !trigger.predicate.matches(&fired) { continue; @@ -361,6 +359,13 @@ pub(crate) fn spawn_event_trigger_subscriber( crate::pipeline_state::stage_label(&fired.after), ); + // Cancel once-mode triggers before dispatching the action so + // that a server restart triggered by the action (e.g. + // rebuild_and_restart) cannot find and replay the trigger. + if trigger.mode == FireMode::Once { + store.cancel(&trigger.id); + } + match &trigger.action { TriggerAction::Mcp { method, args } => { execute_mcp_action(method, args.clone(), &ctx).await; @@ -375,14 +380,6 @@ pub(crate) fn spawn_event_trigger_subscriber( .await; } } - - if trigger.mode == FireMode::Once { - to_cancel.push(trigger.id.clone()); - } - } - - if !to_cancel.is_empty() { - store.cancel_batch(&to_cancel); } } });