Skip to content

Commit b36fb5c

Browse files
authored
fix(cron): run jobs through conversation service (#546)
## Summary - execute cron jobs through aionui-conversation instead of bypassing the conversation turn path - preserve assistant binding for existing-conversation cron jobs and reject assistant changes in that mode - persist cron trigger artifacts before assistant output and emit skill suggestions per conversation Refs iOfficeAI/AionUi#3472 ## Test Plan - cargo fmt --all -- --check - cargo test -p aionui-cron - cargo test -p aionui-conversation - just push -u origin fix/cron-description-optional Co-authored-by: zk <>
1 parent 399f920 commit b36fb5c

11 files changed

Lines changed: 721 additions & 290 deletions

File tree

crates/aionui-app/assets/builtin-skills/auto-inject/cron/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Output `[CRON_LIST]` (nothing else in this message) and wait for the system resp
2626
- **Task already exists and user wants to change it** → Output `[CRON_UPDATE: <job-id>]` to modify in place.
2727
- **Task already exists and user wants something different** → Ask the user how to proceed.
2828

29-
## Create: [CRON_CREATE]
29+
## Create: [ ]
3030

3131
Output this format DIRECTLY (not in code blocks):
3232

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ impl AgentTurnExecutionPort for TeamConversationAdapters {
8080
content: request.content.clone(),
8181
files: request.files.clone(),
8282
inject_skills: Vec::new(),
83+
persist_user_message: false,
84+
user_message_hidden: false,
8385
on_started: on_started.clone(),
8486
})
8587
.await

crates/aionui-app/tests/cron_e2e.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,17 @@ async fn cj1_create_cron_job() {
157157
assert_eq!(data["metadata"]["created_by"], "user");
158158
}
159159

160+
#[tokio::test]
161+
async fn cj1b_create_job_allows_missing_task_description() {
162+
let (mut app, services) = build_app().await;
163+
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
164+
165+
let data = create_job(&mut app, &token, &csrf, create_job_body("No Description")).await;
166+
167+
assert!(data.get("description").is_none());
168+
assert_eq!(data["schedule"]["description"], "every minute");
169+
}
170+
160171
// ── CJ-2: Create three schedule types ────────────────────────────────
161172

162173
#[tokio::test]

crates/aionui-conversation/src/service.rs

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,8 @@ pub struct ConversationAgentTurnRequest {
344344
pub content: String,
345345
pub files: Vec<String>,
346346
pub inject_skills: Vec<String>,
347+
pub persist_user_message: bool,
348+
pub user_message_hidden: bool,
347349
pub on_started: Option<ConversationAgentTurnStartedCallback>,
348350
}
349351

@@ -367,6 +369,7 @@ pub struct ConversationAgentTurnOutcome {
367369
pub conversation_id: String,
368370
pub turn_id: String,
369371
pub status: ConversationAgentTurnStatus,
372+
pub error_message: Option<String>,
370373
pub runtime: ConversationRuntimeSummary,
371374
}
372375

@@ -2605,6 +2608,36 @@ impl ConversationService {
26052608

26062609
let turn_id = Self::mint_turn_id();
26072610
let turn_claim = self.runtime_state.try_claim_turn(&request.conversation_id, &turn_id)?;
2611+
if request.persist_user_message {
2612+
let user_msg_id = Self::mint_msg_id();
2613+
let user_msg = aionui_db::models::MessageRow {
2614+
id: user_msg_id.clone(),
2615+
conversation_id: request.conversation_id.clone(),
2616+
msg_id: Some(user_msg_id),
2617+
r#type: "text".into(),
2618+
content: serde_json::json!({ "content": request.content }).to_string(),
2619+
position: Some("right".into()),
2620+
status: Some("finish".into()),
2621+
hidden: request.user_message_hidden,
2622+
created_at: now_ms(),
2623+
};
2624+
if self
2625+
.runtime_persistence()
2626+
.allows(&request.conversation_id, RuntimeWriteKind::UserMessage)
2627+
&& let Err(e) = self.conversation_repo.insert_message(&user_msg).await
2628+
{
2629+
warn!(
2630+
msg_id = %user_msg.id,
2631+
error = %ErrorChain(&e),
2632+
"Failed to insert agent turn user message"
2633+
);
2634+
let mut turn_claim = turn_claim;
2635+
let was_deleting = turn_claim.release();
2636+
self.complete_released_turn(&request.conversation_id, &turn_id, was_deleting)
2637+
.await;
2638+
return Err(e.into());
2639+
}
2640+
}
26082641
if let Some(on_started) = request.on_started.as_ref() {
26092642
on_started(ConversationAgentTurnStarted {
26102643
conversation_id: request.conversation_id.clone(),
@@ -2633,6 +2666,7 @@ impl ConversationService {
26332666
conversation_id: request.conversation_id.clone(),
26342667
turn_id,
26352668
status: ConversationAgentTurnStatus::Failed,
2669+
error_message: Some(send_error_display_message(&send_error)),
26362670
runtime: self.runtime_summary_for(&request.conversation_id).await,
26372671
});
26382672
}
@@ -2649,7 +2683,7 @@ impl ConversationService {
26492683
content: request.content,
26502684
files: request.files,
26512685
inject_skills: request.inject_skills,
2652-
hidden: false,
2686+
hidden: request.user_message_hidden,
26532687
},
26542688
build_options: build_opts,
26552689
stored_workspace,
@@ -2666,9 +2700,28 @@ impl ConversationService {
26662700
ConversationTurnStatus::Completed => ConversationAgentTurnStatus::Completed,
26672701
ConversationTurnStatus::Failed => ConversationAgentTurnStatus::Failed,
26682702
},
2703+
error_message: result.error_message,
26692704
})
26702705
}
26712706

2707+
pub async fn latest_conversation_error_message(
2708+
&self,
2709+
conversation_id: &str,
2710+
) -> Result<Option<String>, ConversationError> {
2711+
let page = self
2712+
.conversation_repo
2713+
.list_messages_page(
2714+
conversation_id,
2715+
&MessagePageParams {
2716+
limit: 30,
2717+
direction: MessagePageDirection::InitialLatest,
2718+
},
2719+
)
2720+
.await?;
2721+
2722+
Ok(page.items.iter().rev().find_map(error_message_from_message_row))
2723+
}
2724+
26722725
pub(crate) async fn persist_and_broadcast_send_failure_tip(
26732726
&self,
26742727
conversation_id: &str,
@@ -2867,6 +2920,49 @@ impl ConversationService {
28672920
}
28682921
}
28692922

2923+
fn send_error_display_message(error: &AgentSendError) -> String {
2924+
error
2925+
.stream_error()
2926+
.detail
2927+
.as_deref()
2928+
.filter(|detail| !detail.trim().is_empty())
2929+
.unwrap_or(error.stream_error().message.as_str())
2930+
.to_owned()
2931+
}
2932+
2933+
fn error_message_from_message_row(row: &MessageRow) -> Option<String> {
2934+
if row.r#type != "tips" && row.status.as_deref() != Some("error") {
2935+
return None;
2936+
}
2937+
2938+
let content: serde_json::Value = serde_json::from_str(&row.content).ok()?;
2939+
let is_error_tip = content.get("type").and_then(serde_json::Value::as_str) == Some("error")
2940+
|| row.status.as_deref() == Some("error");
2941+
if !is_error_tip {
2942+
return None;
2943+
}
2944+
2945+
[
2946+
content.pointer("/error/detail"),
2947+
content.pointer("/details/detail"),
2948+
content.get("details"),
2949+
content.get("content"),
2950+
content.pointer("/error/message"),
2951+
]
2952+
.into_iter()
2953+
.flatten()
2954+
.filter_map(non_empty_json_string)
2955+
.find(|message| message != "cron conversation turn failed")
2956+
}
2957+
2958+
fn non_empty_json_string(value: &serde_json::Value) -> Option<String> {
2959+
value
2960+
.as_str()
2961+
.map(str::trim)
2962+
.filter(|value| !value.is_empty())
2963+
.map(str::to_owned)
2964+
}
2965+
28702966
// ── Internal Helpers ────────────────────────────────────────────────
28712967

28722968
pub(crate) fn agent_error_top_level_code(error: &AgentError) -> &'static str {

crates/aionui-conversation/src/service_test.rs

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@ use aionui_realtime::EventBroadcaster;
4747
use serde_json::json;
4848
use tokio::sync::{Notify, broadcast};
4949

50-
use crate::ConversationError;
5150
use crate::service::ConversationService;
5251
use crate::skill_resolver::{FixedSkillResolver, ResolvedAgentSkill, SkillResolver};
52+
use crate::{ConversationAgentTurnRequest, ConversationAgentTurnStatus, ConversationError};
5353

5454
#[path = "service_test/acp_error_recovery_test.rs"]
5555
mod acp_error_recovery_test;
@@ -3763,6 +3763,81 @@ async fn send_message_persists_error_tip_when_agent_build_fails() {
37633763
assert_eq!(error_tip_event.data["turn_id"], response.turn_id);
37643764
}
37653765

3766+
#[tokio::test]
3767+
async fn run_agent_turn_returns_error_message_when_agent_build_fails() {
3768+
let task_mgr: Arc<dyn IWorkerTaskManager> =
3769+
Arc::new(FailingBuildTaskManager::new("ACP init failed: config file is invalid"));
3770+
let repo = Arc::new(MockRepo::new());
3771+
let broadcaster = Arc::new(MockBroadcaster::new());
3772+
let service = ConversationService::new(
3773+
std::env::temp_dir(),
3774+
broadcaster,
3775+
Arc::new(FixedSkillResolver { names: vec![] }),
3776+
task_mgr,
3777+
repo,
3778+
Arc::new(StubAgentMetadataRepo),
3779+
Arc::new(StubAcpSessionRepo::default()),
3780+
);
3781+
3782+
let conv = service.create("user_1", make_create_req()).await.unwrap();
3783+
let outcome = service
3784+
.run_agent_turn(ConversationAgentTurnRequest {
3785+
user_id: "user_1".into(),
3786+
conversation_id: conv.id,
3787+
content: "run scheduled task".into(),
3788+
files: Vec::new(),
3789+
inject_skills: Vec::new(),
3790+
persist_user_message: true,
3791+
user_message_hidden: true,
3792+
on_started: None,
3793+
})
3794+
.await
3795+
.unwrap();
3796+
3797+
assert_eq!(outcome.status, ConversationAgentTurnStatus::Failed);
3798+
assert_eq!(
3799+
outcome.error_message.as_deref(),
3800+
Some("ACP init failed: config file is invalid")
3801+
);
3802+
}
3803+
3804+
#[tokio::test]
3805+
async fn latest_conversation_error_message_prefers_error_detail() {
3806+
let (svc, _broadcaster, repo, _task_mgr) = make_service();
3807+
let conv = svc.create("user_1", make_create_req()).await.unwrap();
3808+
repo.insert_message(&MessageRow {
3809+
id: "msg_error".into(),
3810+
conversation_id: conv.id.clone(),
3811+
msg_id: None,
3812+
r#type: "tips".into(),
3813+
content: serde_json::json!({
3814+
"content": "Current agent failed",
3815+
"type": "error",
3816+
"details": {
3817+
"detail": "Cannot resume Claude session with Codex assistant"
3818+
},
3819+
"error": {
3820+
"message": "Current agent failed",
3821+
"detail": "Codex cannot resume a conversation created by Claude"
3822+
}
3823+
})
3824+
.to_string(),
3825+
position: Some("center".into()),
3826+
status: Some("error".into()),
3827+
hidden: false,
3828+
created_at: 10,
3829+
})
3830+
.await
3831+
.unwrap();
3832+
3833+
let message = svc.latest_conversation_error_message(&conv.id).await.unwrap();
3834+
3835+
assert_eq!(
3836+
message.as_deref(),
3837+
Some("Codex cannot resume a conversation created by Claude")
3838+
);
3839+
}
3840+
37663841
#[tokio::test]
37673842
async fn send_message_persists_openclaw_gateway_unreachable_tip_when_turn_build_fails() {
37683843
let (svc, broadcaster, repo, _default_task_mgr) = make_service();

crates/aionui-conversation/src/turn_orchestrator.rs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ pub(crate) enum ConversationTurnStatus {
4343

4444
pub(crate) struct ConversationTurnResult {
4545
pub status: ConversationTurnStatus,
46+
pub error_message: Option<String>,
4647
}
4748

4849
pub(crate) struct ConversationTurnOrchestrator {
@@ -123,7 +124,7 @@ impl ConversationTurnOrchestrator {
123124
error = %ErrorChain(&err),
124125
"Agent task build failed"
125126
);
126-
let failure_message = err.to_string();
127+
let failure_message = send_error_display_message(&send_error);
127128
record_agent_session_failure(
128129
&self.service,
129130
availability_agent_id.as_deref(),
@@ -141,6 +142,7 @@ impl ConversationTurnOrchestrator {
141142
.await;
142143
return Err(ConversationTurnResult {
143144
status: ConversationTurnStatus::Failed,
145+
error_message: Some(failure_message),
144146
});
145147
}
146148
};
@@ -151,6 +153,7 @@ impl ConversationTurnOrchestrator {
151153
.await
152154
{
153155
let top_level_code = err.error_code();
156+
let failure_message = err.to_string();
154157
let send_error = AgentSendError::from_agent_error(err.to_agent_error());
155158
error!(
156159
conversation_id = %input.conv_id,
@@ -169,6 +172,7 @@ impl ConversationTurnOrchestrator {
169172
.await;
170173
return Err(ConversationTurnResult {
171174
status: ConversationTurnStatus::Failed,
175+
error_message: Some(failure_message),
172176
});
173177
}
174178

@@ -220,7 +224,7 @@ impl ConversationTurnOrchestrator {
220224

221225
tokio::spawn(async move {
222226
if let Err(e) = send_agent.send_message(current_send).await {
223-
let failure_message = availability_failure_message(&e);
227+
let failure_message = send_error_display_message(&e);
224228
record_agent_session_failure(
225229
&feedback_service,
226230
feedback_agent_id.as_deref(),
@@ -317,6 +321,7 @@ impl ConversationTurnOrchestrator {
317321
};
318322
let mut replayed = false;
319323
let mut replay_started_at = None;
324+
let mut final_error_message;
320325

321326
info!(conversation_id = %conv_id, turn_id = %turn_id, "conversation turn orchestrator started");
322327

@@ -339,12 +344,14 @@ impl ConversationTurnOrchestrator {
339344
{
340345
Ok(result) => result,
341346
Err(result) => {
347+
final_error_message = result.error_message;
342348
break result.status == ConversationTurnStatus::Failed;
343349
}
344350
};
345351

346352
let lifecycle = runtime_state.lifecycle_for(&conv_id);
347353
if !attempt_result.outcome.terminal.is_error() {
354+
final_error_message = None;
348355
if replayed {
349356
info!(
350357
conversation_id = %conv_id,
@@ -358,6 +365,7 @@ impl ConversationTurnOrchestrator {
358365
}
359366
break false;
360367
}
368+
final_error_message = turn_attempt_error_message(&attempt_result.summary);
361369
if replayed {
362370
warn!(
363371
conversation_id = %conv_id,
@@ -447,6 +455,7 @@ impl ConversationTurnOrchestrator {
447455
} else {
448456
ConversationTurnStatus::Completed
449457
},
458+
error_message: if final_failed { final_error_message } else { None },
450459
}
451460
}
452461
}
@@ -463,14 +472,25 @@ fn availability_agent_id(options: &BuildTaskOptions) -> Option<String> {
463472
}
464473
}
465474

466-
fn availability_failure_message(error: &AgentSendError) -> String {
475+
fn send_error_display_message(error: &AgentSendError) -> String {
467476
error
468477
.stream_error()
469478
.detail
470479
.clone()
471480
.unwrap_or_else(|| error.stream_error().message.clone())
472481
}
473482

483+
fn turn_attempt_error_message(summary: &TurnAttemptSummary) -> Option<String> {
484+
summary.terminal_error.as_ref().map(|error| {
485+
error
486+
.detail
487+
.as_deref()
488+
.filter(|detail| !detail.trim().is_empty())
489+
.unwrap_or(error.message.as_str())
490+
.to_owned()
491+
})
492+
}
493+
474494
async fn record_agent_session_failure(
475495
service: &ConversationService,
476496
agent_id: Option<&str>,

0 commit comments

Comments
 (0)