Skip to content

Commit cf0a9bd

Browse files
authored
fix(cron): enforce full-auto mode for scheduled tasks (#576)
## Summary - preserve cron jobs when related conversations are deleted and create replacement conversations for existing-mode jobs when needed - enforce scheduled task runtime mode through the agent runtime before dispatch, using assistant/agent metadata full-auto mode values instead of hardcoded yolo ids - keep existing-mode replacement conversations eligible for cron skill editing and return the active conversation when run-now is clicked while it is already running ## Tests - cargo fmt --all -- --check - git diff --check - cargo nextest run -p aionui-cron - cargo nextest run -p aionui-conversation run_agent_turn_applies_required_runtime_mode_after_stream_subscription save_acp_runtime_mode_updates_runtime_mode_config_selection - cargo check -p aionui-app - just push -u origin fix/cron-skill-yolo-mode Co-authored-by: zk <>
1 parent dd9f299 commit cf0a9bd

9 files changed

Lines changed: 439 additions & 60 deletions

File tree

crates/aionui-app/src/router/team_conversation_adapters.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ impl AgentTurnExecutionPort for TeamConversationAdapters {
8080
content: request.content.clone(),
8181
files: request.files.clone(),
8282
inject_skills: Vec::new(),
83+
required_runtime_mode: None,
8384
persist_user_message: false,
8485
user_message_hidden: false,
8586
on_started: on_started.clone(),

crates/aionui-conversation/src/service.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ pub struct ConversationAgentTurnRequest {
334334
pub content: String,
335335
pub files: Vec<String>,
336336
pub inject_skills: Vec<String>,
337+
pub required_runtime_mode: Option<String>,
337338
pub persist_user_message: bool,
338339
pub user_message_hidden: bool,
339340
pub on_started: Option<ConversationAgentTurnStartedCallback>,
@@ -1997,8 +1998,21 @@ impl ConversationService {
19971998
}
19981999

19992000
pub async fn save_acp_runtime_mode(&self, conversation_id: &str, mode: &str) -> Result<(), ConversationError> {
2001+
let runtime_state = self
2002+
.acp_session_repo
2003+
.load_runtime_state(conversation_id)
2004+
.await
2005+
.map_err(|e| ConversationError::internal(format!("Failed to load runtime mode state: {e}")))?;
2006+
let mut config_selections = runtime_state
2007+
.and_then(|state| state.config_selections_json)
2008+
.and_then(|raw| serde_json::from_str::<HashMap<String, String>>(&raw).ok())
2009+
.unwrap_or_default();
2010+
config_selections.insert("mode".to_owned(), mode.to_owned());
2011+
let config_selections_json = serde_json::to_string(&config_selections)
2012+
.map_err(|e| ConversationError::internal(format!("Failed to serialize runtime mode selection: {e}")))?;
20002013
let params = SaveRuntimeStateParams {
20012014
current_mode_id: Some(Some(mode)),
2015+
config_selections_json: Some(Some(config_selections_json.as_str())),
20022016
..Default::default()
20032017
};
20042018
self.acp_session_repo
@@ -2670,6 +2684,7 @@ impl ConversationService {
26702684
user_id: user_id.to_owned(),
26712685
conversation: row,
26722686
request: req,
2687+
required_runtime_mode: None,
26732688
build_options: build_opts,
26742689
stored_workspace,
26752690
turn_id: turn_id.clone(),
@@ -2793,6 +2808,7 @@ impl ConversationService {
27932808
inject_skills: request.inject_skills,
27942809
hidden: request.user_message_hidden,
27952810
},
2811+
required_runtime_mode: request.required_runtime_mode,
27962812
build_options: build_opts,
27972813
stored_workspace,
27982814
turn_id: turn_id.clone(),

crates/aionui-conversation/src/service_test.rs

Lines changed: 126 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -862,13 +862,15 @@ struct RuntimeStateSaveCall {
862862
conversation_id: String,
863863
current_mode_id: Option<Option<String>>,
864864
current_model_id: Option<Option<String>>,
865+
config_selections_json: Option<Option<String>>,
865866
}
866867

867868
#[derive(Default)]
868869
struct StubAcpSessionRepo {
869870
create_calls: Mutex<Vec<CreateAcpSessionCall>>,
870871
runtime_state_saves: Mutex<Vec<RuntimeStateSaveCall>>,
871872
session_id: Mutex<Option<String>>,
873+
runtime_state: Mutex<Option<PersistedSessionState>>,
872874
}
873875

874876
impl StubAcpSessionRepo {
@@ -881,6 +883,16 @@ impl StubAcpSessionRepo {
881883
create_calls: Mutex::new(Vec::new()),
882884
runtime_state_saves: Mutex::new(Vec::new()),
883885
session_id: Mutex::new(Some(session_id.into())),
886+
runtime_state: Mutex::new(None),
887+
}
888+
}
889+
890+
fn with_runtime_state(state: PersistedSessionState) -> Self {
891+
Self {
892+
create_calls: Mutex::new(Vec::new()),
893+
runtime_state_saves: Mutex::new(Vec::new()),
894+
session_id: Mutex::new(None),
895+
runtime_state: Mutex::new(Some(state)),
884896
}
885897
}
886898

@@ -941,10 +953,12 @@ impl IAcpSessionRepository for StubAcpSessionRepo {
941953
Ok(false)
942954
}
943955
async fn load_runtime_state(&self, _conversation_id: &str) -> Result<Option<PersistedSessionState>, DbError> {
944-
Ok(Some(PersistedSessionState {
945-
current_model_id: Some("deepseek-v4-pro".to_owned()),
946-
..Default::default()
947-
}))
956+
Ok(Some(self.runtime_state.lock().unwrap().clone().unwrap_or(
957+
PersistedSessionState {
958+
current_model_id: Some("deepseek-v4-pro".to_owned()),
959+
..Default::default()
960+
},
961+
)))
948962
}
949963
async fn save_runtime_state(
950964
&self,
@@ -955,6 +969,7 @@ impl IAcpSessionRepository for StubAcpSessionRepo {
955969
conversation_id: conversation_id.to_owned(),
956970
current_mode_id: params.current_mode_id.map(|outer| outer.map(ToOwned::to_owned)),
957971
current_model_id: params.current_model_id.map(|outer| outer.map(ToOwned::to_owned)),
972+
config_selections_json: params.config_selections_json.map(|outer| outer.map(ToOwned::to_owned)),
958973
});
959974
Ok(true)
960975
}
@@ -2528,6 +2543,11 @@ impl MockAgent {
25282543
self
25292544
}
25302545

2546+
fn with_mode(self, mode: &str) -> Self {
2547+
*self.mode.lock().unwrap() = mode.to_owned();
2548+
self
2549+
}
2550+
25312551
fn with_set_config_option_response(self, response: SetConfigOptionResponse) -> Self {
25322552
*self.set_config_option_response.lock().unwrap() = Some(response);
25332553
self
@@ -2634,16 +2654,30 @@ impl IMockAgent for MockAgent {
26342654
.lock()
26352655
.unwrap()
26362656
.push((option_id.to_owned(), value.to_owned()));
2657+
if option_id == "mode" {
2658+
*self.mode.lock().unwrap() = value.to_owned();
2659+
}
26372660
if let Some(error) = self.set_config_option_error.lock().unwrap().take() {
26382661
return Err(error);
26392662
}
26402663
if let Some(response) = self.set_config_option_response.lock().unwrap().clone() {
2664+
if let Some(config_options) = response.config_options.as_ref() {
2665+
let _ = self.event_tx.send(AgentStreamEvent::AcpConfigOption(json!({
2666+
"config_options": config_options,
2667+
})));
2668+
}
26412669
return Ok(response);
26422670
}
2643-
Ok(SetConfigOptionResponse {
2671+
let response = SetConfigOptionResponse {
26442672
confirmation: ConfigOptionConfirmation::Observed,
26452673
config_options: Some(self.config_options.lock().unwrap().clone()),
2646-
})
2674+
};
2675+
if let Some(config_options) = response.config_options.as_ref() {
2676+
let _ = self.event_tx.send(AgentStreamEvent::AcpConfigOption(json!({
2677+
"config_options": config_options,
2678+
})));
2679+
}
2680+
Ok(response)
26472681
}
26482682
}
26492683

@@ -3408,6 +3442,7 @@ async fn run_agent_turn_injects_conversation_runtime_context() {
34083442
content: "run scheduled task".into(),
34093443
files: Vec::new(),
34103444
inject_skills: Vec::new(),
3445+
required_runtime_mode: None,
34113446
persist_user_message: true,
34123447
user_message_hidden: true,
34133448
on_started: None,
@@ -3583,6 +3618,90 @@ async fn set_config_option_returns_observed_confirmation() {
35833618
);
35843619
}
35853620

3621+
#[tokio::test]
3622+
async fn save_acp_runtime_mode_updates_runtime_mode_config_selection() {
3623+
let acp_repo = Arc::new(StubAcpSessionRepo::with_runtime_state(PersistedSessionState {
3624+
current_mode_id: Some("read-only".to_owned()),
3625+
current_model_id: Some("gpt-5.3-codex".to_owned()),
3626+
config_selections_json: Some(
3627+
serde_json::json!({
3628+
"mode": "read-only",
3629+
"model": "gpt-5.3-codex",
3630+
"reasoning_effort": "low"
3631+
})
3632+
.to_string(),
3633+
),
3634+
context_usage_json: None,
3635+
}));
3636+
let (svc, _, _, _) = make_service_with_resolver_and_acp_session_repo(
3637+
Arc::new(FixedSkillResolver { names: vec![] }),
3638+
acp_repo.clone(),
3639+
);
3640+
svc.save_acp_runtime_mode("conv_1", "full-access").await.unwrap();
3641+
3642+
let saves = acp_repo.runtime_state_saves();
3643+
assert_eq!(saves.len(), 1);
3644+
assert_eq!(saves[0].current_mode_id, Some(Some("full-access".to_owned())));
3645+
let selections: serde_json::Value =
3646+
serde_json::from_str(saves[0].config_selections_json.as_ref().unwrap().as_ref().unwrap()).unwrap();
3647+
assert_eq!(selections["mode"], "full-access");
3648+
assert_eq!(selections["model"], "gpt-5.3-codex");
3649+
assert_eq!(selections["reasoning_effort"], "low");
3650+
}
3651+
3652+
#[tokio::test]
3653+
async fn run_agent_turn_applies_required_runtime_mode_after_stream_subscription() {
3654+
let task_mgr = Arc::new(MockTaskManager::new());
3655+
let (svc, broadcaster, _repo) = make_service_with_mock_task_manager(task_mgr.clone());
3656+
let conv = svc.create("user_1", make_create_req()).await.unwrap();
3657+
broadcaster.take_events();
3658+
3659+
let agent = Arc::new(
3660+
MockAgent::new(&conv.id)
3661+
.with_mode("yolo")
3662+
.with_config_options(vec![AcpConfigOptionDto {
3663+
id: "mode".to_owned(),
3664+
name: Some("Mode".to_owned()),
3665+
label: None,
3666+
description: None,
3667+
category: Some("mode".to_owned()),
3668+
option_type: "select".to_owned(),
3669+
current_value: Some("yolo".to_owned()),
3670+
options: Vec::new(),
3671+
}]),
3672+
);
3673+
task_mgr.insert_agent(&conv.id, AgentInstance::Mock(agent.clone()));
3674+
3675+
let outcome = svc
3676+
.run_agent_turn(ConversationAgentTurnRequest {
3677+
user_id: "user_1".to_owned(),
3678+
conversation_id: conv.id.clone(),
3679+
content: "run scheduled task".to_owned(),
3680+
files: Vec::new(),
3681+
inject_skills: Vec::new(),
3682+
required_runtime_mode: Some("yolo".to_owned()),
3683+
persist_user_message: true,
3684+
user_message_hidden: true,
3685+
on_started: None,
3686+
})
3687+
.await
3688+
.unwrap();
3689+
3690+
assert_eq!(outcome.status, ConversationAgentTurnStatus::Completed);
3691+
assert_eq!(
3692+
agent.set_config_option_calls.lock().unwrap().as_slice(),
3693+
&[("mode".to_owned(), "yolo".to_owned())]
3694+
);
3695+
let events = broadcaster.take_events();
3696+
let config_event = events
3697+
.iter()
3698+
.find(|event| event.name == "message.stream" && event.data["type"] == "acp_config_option")
3699+
.expect("runtime mode switch should broadcast config option snapshot");
3700+
assert_eq!(config_event.data["conversation_id"], conv.id);
3701+
assert_eq!(config_event.data["data"]["config_options"][0]["id"], "mode");
3702+
assert_eq!(config_event.data["data"]["config_options"][0]["current_value"], "yolo");
3703+
}
3704+
35863705
#[tokio::test]
35873706
async fn set_config_option_evicts_task_when_acp_protocol_is_not_connected() {
35883707
let task_mgr = Arc::new(MockTaskManager::new());
@@ -4304,6 +4423,7 @@ async fn run_agent_turn_returns_error_message_when_agent_build_fails() {
43044423
content: "run scheduled task".into(),
43054424
files: Vec::new(),
43064425
inject_skills: Vec::new(),
4426+
required_runtime_mode: None,
43074427
persist_user_message: true,
43084428
user_message_hidden: true,
43094429
on_started: None,

crates/aionui-conversation/src/service_test/acp_error_recovery_test.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ async fn send_message_clears_persisted_acp_model_after_model_not_found() {
102102
conversation_id: conv.id.clone(),
103103
current_mode_id: None,
104104
current_model_id: Some(None),
105+
config_selections_json: None,
105106
}]
106107
);
107108

crates/aionui-conversation/src/turn_orchestrator.rs

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::sync::Arc;
22

33
use aionui_ai_agent::types::{BuildTaskOptions, SendMessageData};
4-
use aionui_ai_agent::{AgentSendError, AgentSessionKind, IWorkerTaskManager};
4+
use aionui_ai_agent::{AgentError, AgentInstance, AgentSendError, AgentSessionKind, IWorkerTaskManager};
55
use aionui_common::{AgentType, ConversationStatus, ErrorChain, now_ms};
66
use aionui_db::models::ConversationRow;
77
use tokio::sync::oneshot;
@@ -29,6 +29,7 @@ pub(crate) struct TurnStartInput {
2929
pub user_id: String,
3030
pub conversation: ConversationRow,
3131
pub request: SendMessageRequest,
32+
pub required_runtime_mode: Option<String>,
3233
pub build_options: BuildTaskOptions,
3334
pub stored_workspace: String,
3435
pub turn_id: String,
@@ -60,6 +61,7 @@ struct TurnAttemptInput {
6061
send: SendMessageData,
6162
msg_id: String,
6263
allowed_skill_names: Vec<String>,
64+
required_runtime_mode: Option<String>,
6365
continuation_count: usize,
6466
defer_clean_terminal_errors: bool,
6567
}
@@ -214,6 +216,47 @@ impl ConversationTurnOrchestrator {
214216
.with_defer_clean_terminal_errors(defer_clean_terminal_errors);
215217

216218
let rx = agent.subscribe();
219+
if let Some(mode) = input
220+
.required_runtime_mode
221+
.as_deref()
222+
.map(str::trim)
223+
.filter(|mode| !mode.is_empty())
224+
{
225+
match apply_required_runtime_mode(&agent, mode).await {
226+
Ok(()) => {
227+
info!(
228+
conversation_id = %input.conv_id,
229+
turn_id = %input.turn_id,
230+
mode,
231+
"Confirmed required runtime mode before agent turn"
232+
);
233+
}
234+
Err(err) => {
235+
let top_level_code = agent_error_top_level_code(&err);
236+
let failure_message = err.to_string();
237+
let send_error = AgentSendError::from_agent_error(err);
238+
error!(
239+
conversation_id = %input.conv_id,
240+
turn_id = %input.turn_id,
241+
mode,
242+
error = %failure_message,
243+
"Failed to apply required runtime mode before agent turn"
244+
);
245+
self.service
246+
.persist_and_broadcast_send_failure_tip(
247+
&input.conv_id,
248+
&input.turn_id,
249+
&send_error,
250+
Some(top_level_code),
251+
)
252+
.await;
253+
return Err(ConversationTurnResult {
254+
status: ConversationTurnStatus::Failed,
255+
error_message: Some(failure_message),
256+
});
257+
}
258+
}
259+
}
217260
let send_agent = agent.clone();
218261
let conv_id_send = input.conv_id.clone();
219262
let turn_id_for_send = input.turn_id.clone();
@@ -336,6 +379,7 @@ impl ConversationTurnOrchestrator {
336379
send: initial_send.clone(),
337380
msg_id: first_turn_msg_id.clone(),
338381
allowed_skill_names: allowed_skill_names.clone(),
382+
required_runtime_mode: input.required_runtime_mode.clone(),
339383
continuation_count: 0,
340384
defer_clean_terminal_errors: !replayed,
341385
})
@@ -471,6 +515,11 @@ fn availability_agent_id(options: &BuildTaskOptions) -> Option<String> {
471515
}
472516
}
473517

518+
async fn apply_required_runtime_mode(agent: &AgentInstance, mode: &str) -> Result<(), AgentError> {
519+
agent.set_config_option("mode", mode).await?;
520+
Ok(())
521+
}
522+
474523
fn send_error_display_message(error: &AgentSendError) -> String {
475524
error
476525
.stream_error()

crates/aionui-conversation/tests/stream_relay_tool_call.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,7 @@ async fn run_agent_turn_with_empty_call_id_tool_call_is_not_persisted() {
322322
content: "run glob".into(),
323323
files: Vec::new(),
324324
inject_skills: Vec::new(),
325+
required_runtime_mode: None,
325326
persist_user_message: false,
326327
user_message_hidden: false,
327328
on_started: None,

0 commit comments

Comments
 (0)