Compare commits

..
Author SHA1 Message Date
Claude a5b5d02ab1 fix(clippy): replace find().is_none() with !any() in user stats test
https://claude.ai/code/session_01Nm95eCjdrwxDjwkHTRieZs
2026-03-28 19:22:38 +00:00
ZakiandClaude a0020b22a5 fix(routines): address review feedback on retry loop
- Remove outer retry for LlmFailed errors since RetryProvider already
  handles transient LLM failures with its own bounded budget, preventing
  multiplicative retry counts

- Preserve Option<i32> semantics for token accumulation: None means
  "unknown/not tracked" rather than converting to Some(0) via
  unwrap_or(0), so downstream API/UI correctly distinguishes null
  from zero

- Add partial_tokens field to EmptyResponse and TruncatedResponse
  variants so token usage from those failed attempts is captured in the
  retry accumulator

- Persist accumulated token total on final failure path so usage from
  earlier retry attempts is not silently discarded

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-28 19:16:10 +00:00
Claude b3e09c3827 style: fix rustfmt formatting in routine_engine.rs
https://claude.ai/code/session_01CsP5wMZ2evEMHgGghjAfR1
2026-03-28 19:16:10 +00:00
ZakiandClaude eba088f30e fix(routines): address PR review feedback for bounded retry (#1320)
- Skip retry for tools-enabled routines to prevent duplicate side effects
- Replace fragile PERMANENT_LLM_PATTERNS substring matching with a
  retryable bool field on RoutineError::LlmFailed, set at the LlmError
  conversion site using llm::retry::is_retryable()
- Use saturating_add for token accumulation

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-28 19:16:10 +00:00
ZakiandClaude 0d82ce5d4c fix: use structured tracing for transient routine retry logging [skip-regression-check]
Replace tracing::warn! with tracing::event! targeting "transient_routine_errors"
for better log filtering and structured field capture on retry attempts.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-28 19:16:10 +00:00
ZakiandClaude 3a27cd3561 fix(routines): classify permanent LLM failures, accumulate tokens, warn on tool retry (#1320)
Address three review comments on the bounded-retry implementation:

1. Permanent LLM failures no longer retried: `is_retryable()` now inspects
   the `LlmFailed` reason string for known permanent patterns (auth,
   content policy, context length, model not available, moderation).
   These map to `LlmError` variants already classified as non-retryable
   by the LLM retry layer.

2. Token usage accumulated across retries: `RoutineError::LlmFailed` now
   carries an optional `partial_tokens` field populated from tokens consumed
   before the failure. The retry loop sums partial tokens from failed
   attempts with the final successful attempt's tokens.

3. Tool loop retry limitation documented: added a code comment explaining
   that the retry wraps the entire `execute_lightweight()` call, and a
   warning log when retrying a tools-enabled routine so operators know
   side effects may be repeated.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-28 19:16:10 +00:00
Claude c20fd6ec3a fix(deps): resolve RUSTSEC-2026-0049 rustls-webpki CRL advisory
Update rustls-webpki 0.103.9 -> 0.103.10 and ignore the advisory for
0.102.8 which is pinned by libsql's rustls 0.22.4 dependency chain.

https://claude.ai/code/session_01MmxvBgAMn4m45pZguFKBEX
2026-03-28 19:16:09 +00:00
Claude 18026fcb7c style: run cargo fmt to fix formatting
https://claude.ai/code/session_01Nv2TJ3so5WQqpRUcirAhT3
2026-03-28 19:16:09 +00:00
ZakiandClaude 841cf5fe76 fix(routines): add bounded retry for transient lightweight execution failures (#1320)
Lightweight routine executions that fail with transient errors (LLM
failures, empty responses, truncated responses) are now retried up to 3
times with exponential backoff (1s, 2s, 4s) before reporting failure.

Full-job routines are not retried since the scheduler/watcher handles
their lifecycle. Hard failures (disabled, not found, auth, DB errors)
fail immediately without retry.

Adds RoutineError::is_retryable() to classify transient vs hard errors,
with comprehensive regression tests covering all error variants.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-28 19:16:09 +00:00
3 changed files with 402 additions and 498 deletions
Generated
+6 -125
View File
@@ -1510,7 +1510,7 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "980c2afde4af43d6a05c5be738f9eae595cff86dce1f38f88b95058a98c027f3"
dependencies = [
"crossterm 0.29.0",
"crossterm",
]
[[package]]
@@ -1731,7 +1731,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04a63daf06a168535c74ab97cdba3ed4fa5d4f32cb36e437dcceb83d66854b7c"
dependencies = [
"crokey-proc_macros",
"crossterm 0.29.0",
"crossterm",
"once_cell",
"serde",
"strict",
@@ -1743,7 +1743,7 @@ version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "847f11a14855fc490bd5d059821895c53e77eeb3c2b73ee3dded7ce77c93b231"
dependencies = [
"crossterm 0.29.0",
"crossterm",
"proc-macro2",
"quote",
"strict",
@@ -1817,22 +1817,6 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crossterm"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags 2.11.0",
"crossterm_winapi",
"mio",
"parking_lot",
"rustix 0.38.44",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm"
version = "0.29.0"
@@ -2492,21 +2476,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -3149,6 +3118,7 @@ dependencies = [
"tokio",
"tokio-rustls 0.26.4",
"tower-service",
"webpki-roots 1.0.6",
]
[[package]]
@@ -3163,22 +3133,6 @@ dependencies = [
"tokio-io-timeout",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper 1.8.1",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -3456,7 +3410,7 @@ dependencies = [
"clap_complete",
"criterion",
"cron",
"crossterm 0.28.1",
"crossterm",
"deadpool-postgres",
"dirs 6.0.0",
"dotenvy",
@@ -4135,23 +4089,6 @@ dependencies = [
"rand 0.8.5",
]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe 0.2.1",
"openssl-sys",
"schannel",
"security-framework 3.7.0",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
@@ -4374,32 +4311,6 @@ dependencies = [
"pathdiff",
]
[[package]]
name = "openssl"
version = "0.10.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
@@ -4412,18 +4323,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.112"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -5413,13 +5312,11 @@ dependencies = [
"http-body-util",
"hyper 1.8.1",
"hyper-rustls 0.27.7",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"mime_guess",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -5431,7 +5328,6 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper 1.0.2",
"tokio",
"tokio-native-tls",
"tokio-rustls 0.26.4",
"tokio-util",
"tower 0.5.3",
@@ -5442,6 +5338,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots 1.0.6",
]
[[package]]
@@ -6774,16 +6671,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-postgres"
version = "0.7.16"
@@ -7467,12 +7354,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
+267 -370
View File
@@ -715,7 +715,6 @@ impl RoutineEngine {
status,
Some(summary),
thread_id.as_deref(),
run.job_id,
)
.await;
@@ -1086,52 +1085,138 @@ struct EngineContext {
}
/// Execute a routine run. Handles both lightweight and full_job modes.
async fn execute_routine(ctx: EngineContext, routine: Routine, mut run: RoutineRun) {
async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun) {
// Increment running count (atomic: survives panics in the execution below)
ctx.running_count.fetch_add(1, Ordering::Relaxed);
let result = match &routine.action {
RoutineAction::Lightweight {
prompt,
context_paths,
max_tokens,
use_tools,
max_tool_rounds,
} => {
execute_lightweight(
&ctx,
&routine,
prompt,
context_paths,
*max_tokens,
*use_tools,
*max_tool_rounds,
)
.await
// Retry constants for transient lightweight execution failures.
const MAX_RETRIES: u32 = 3;
const BASE_DELAY_MS: u64 = 1000;
let is_lightweight = matches!(routine.action, RoutineAction::Lightweight { .. });
// The retry block returns both the execution result and any accumulated
// token count so that usage is preserved even on final failure.
let (result, accumulated_tokens) = {
let mut attempt = 0u32;
// Track accumulated tokens as Option to preserve None semantics:
// None = no attempt reported tokens; Some(n) = at least one attempt did.
let mut accumulated_tokens: Option<i32> = None;
let uses_tools = matches!(
routine.action,
RoutineAction::Lightweight {
use_tools: true,
..
}
) && ctx.config.lightweight_tools_enabled;
/// Extract partial_tokens from any RoutineError variant that carries them.
fn extract_partial_tokens(e: &RoutineError) -> Option<i32> {
match e {
RoutineError::LlmFailed {
partial_tokens: Some(t),
..
}
| RoutineError::EmptyResponse {
partial_tokens: Some(t),
}
| RoutineError::TruncatedResponse {
partial_tokens: Some(t),
} => Some(*t),
_ => None,
}
}
RoutineAction::FullJob {
title,
description,
max_iterations,
} => {
let execution = FullJobExecutionConfig {
title,
description,
max_iterations: *max_iterations,
/// Merge an optional partial token count into the accumulator,
/// only materializing Some when at least one source had Some.
fn accumulate(acc: Option<i32>, partial: Option<i32>) -> Option<i32> {
match (acc, partial) {
(Some(a), Some(p)) => Some(a.saturating_add(p)),
(Some(a), None) => Some(a),
(None, p) => p,
}
}
loop {
let execution_result = match &routine.action {
RoutineAction::Lightweight {
prompt,
context_paths,
max_tokens,
use_tools,
max_tool_rounds,
} => {
execute_lightweight(
&ctx,
&routine,
prompt,
context_paths,
*max_tokens,
*use_tools,
*max_tool_rounds,
)
.await
}
RoutineAction::FullJob {
title,
description,
max_iterations,
} => {
let execution = FullJobExecutionConfig {
title,
description,
max_iterations: *max_iterations,
};
execute_full_job(&ctx, &routine, &run, &execution).await
}
};
execute_full_job(&ctx, &routine, &mut run, &execution).await
match execution_result {
Ok((status, summary, tokens)) => {
// Merge tokens: only produce Some when at least one source had Some.
let total = accumulate(accumulated_tokens, tokens);
break (Ok((status, summary, total)), accumulated_tokens);
}
Err(ref e)
if is_lightweight
&& !uses_tools
&& e.is_retryable()
// Skip outer retry for LlmFailed — RetryProvider already
// retries transient LLM errors with its own budget. Retrying
// here would create a multiplicative retry count.
&& !matches!(e, RoutineError::LlmFailed { .. })
&& attempt < MAX_RETRIES =>
{
// Accumulate partial tokens from the failed attempt.
accumulated_tokens = accumulate(accumulated_tokens, extract_partial_tokens(e));
attempt += 1;
let delay = Duration::from_millis(
BASE_DELAY_MS.saturating_mul(2u64.saturating_pow(attempt - 1)),
);
tracing::event!(target: "transient_routine_errors", tracing::Level::WARN, routine = %routine.name, attempt = attempt, max_retries = MAX_RETRIES, delay_ms = delay.as_millis() as u64, "Transient routine error, retrying: {}", e);
tokio::time::sleep(delay).await;
}
Err(e) => {
// Accumulate tokens from the final failed attempt.
accumulated_tokens = accumulate(accumulated_tokens, extract_partial_tokens(&e));
break (Err(e), accumulated_tokens);
}
}
}
};
// Decrement running count
ctx.running_count.fetch_sub(1, Ordering::Relaxed);
// Process result
// Process result — on failure, preserve accumulated token total from
// earlier retry attempts so usage reporting stays accurate.
let (status, summary, tokens) = match result {
Ok(execution) => execution,
Err(e) => {
tracing::error!(routine = %routine.name, "Execution failed: {}", e);
(RunStatus::Failed, Some(e.to_string()), None)
(RunStatus::Failed, Some(e.to_string()), accumulated_tokens)
}
};
@@ -1219,7 +1304,6 @@ async fn execute_routine(ctx: EngineContext, routine: Routine, mut run: RoutineR
status,
summary.as_deref(),
thread_id.as_deref(),
run.job_id,
)
.await;
}
@@ -1255,7 +1339,7 @@ struct FullJobExecutionConfig<'a> {
async fn execute_full_job(
ctx: &EngineContext,
routine: &Routine,
run: &mut RoutineRun,
run: &RoutineRun,
execution: &FullJobExecutionConfig<'_>,
) -> Result<(RunStatus, Option<String>, Option<i32>), RoutineError> {
match ctx.sandbox_readiness {
@@ -1317,9 +1401,6 @@ async fn execute_full_job(
reason: format!("failed to link run to job: {e}"),
})?;
// Keep the in-memory struct in sync so send_notification can read run.job_id.
run.job_id = Some(job_id);
tracing::info!(
routine = %routine.name,
job_id = %job_id,
@@ -1516,13 +1597,14 @@ async fn execute_lightweight_no_tools(
.with_max_tokens(effective_max_tokens)
.with_temperature(0.3);
let response = ctx
.llm
.complete(request)
.await
.map_err(|e| RoutineError::LlmFailed {
let response = ctx.llm.complete(request).await.map_err(|e| {
let retryable = crate::llm::retry::is_retryable(&e);
RoutineError::LlmFailed {
reason: e.to_string(),
})?;
partial_tokens: None,
retryable,
}
})?;
handle_text_response(
&response.content,
@@ -1543,12 +1625,18 @@ fn handle_text_response(
) -> Result<(RunStatus, Option<String>, Option<i32>), RoutineError> {
let content = content.trim();
// Empty content guard
// Empty content guard — carry consumed tokens so the retry loop can
// accumulate them even when the response shape is invalid.
if content.is_empty() {
let consumed = Some((total_input_tokens + total_output_tokens) as i32);
return if finish_reason == FinishReason::Length {
Err(RoutineError::TruncatedResponse)
Err(RoutineError::TruncatedResponse {
partial_tokens: consumed,
})
} else {
Err(RoutineError::EmptyResponse)
Err(RoutineError::EmptyResponse {
partial_tokens: consumed,
})
};
}
@@ -1626,13 +1714,15 @@ async fn execute_lightweight_with_tools(
.with_max_tokens(effective_max_tokens)
.with_temperature(0.3);
let response =
ctx.llm
.complete(request)
.await
.map_err(|e| RoutineError::LlmFailed {
reason: e.to_string(),
})?;
let response = ctx.llm.complete(request).await.map_err(|e| {
let partial = (total_input_tokens + total_output_tokens) as i32;
let retryable = crate::llm::retry::is_retryable(&e);
RoutineError::LlmFailed {
reason: e.to_string(),
partial_tokens: if partial > 0 { Some(partial) } else { None },
retryable,
}
})?;
total_input_tokens += response.input_tokens;
total_output_tokens += response.output_tokens;
@@ -1659,8 +1749,12 @@ async fn execute_lightweight_with_tools(
.with_temperature(0.3);
let response = ctx.llm.complete_with_tools(request).await.map_err(|e| {
let partial = (total_input_tokens + total_output_tokens) as i32;
let retryable = crate::llm::retry::is_retryable(&e);
RoutineError::LlmFailed {
reason: e.to_string(),
partial_tokens: if partial > 0 { Some(partial) } else { None },
retryable,
}
})?;
@@ -1832,16 +1926,6 @@ async fn execute_routine_tool(
Ok(result_str)
}
/// Human-readable label for a run status, suitable for user-facing notifications.
fn status_display_label(status: RunStatus) -> &'static str {
match status {
RunStatus::Ok => "Completed",
RunStatus::Attention => "Needs attention",
RunStatus::Failed => "Failed",
RunStatus::Running => "Running",
}
}
/// Send a notification based on the routine's notify config and run status.
#[allow(clippy::too_many_arguments)]
async fn send_notification(
@@ -1852,7 +1936,6 @@ async fn send_notification(
status: RunStatus,
summary: Option<&str>,
thread_id: Option<&str>,
job_id: Option<Uuid>,
) {
let should_notify = match status {
RunStatus::Ok => notify.on_success,
@@ -1872,37 +1955,23 @@ async fn send_notification(
RunStatus::Running => "",
};
let label = status_display_label(status);
let message = match summary {
Some(s) => {
let sanitized = sanitize_summary(s);
format!(
"{} *Routine '{}'*: {}\n\n{}",
icon, routine_name, label, sanitized
)
}
None => format!("{} *Routine '{}'*: {}", icon, routine_name, label),
Some(s) => format!("{} *Routine '{}'*: {}\n\n{}", icon, routine_name, status, s),
None => format!("{} *Routine '{}'*: {}", icon, routine_name, status),
};
let mut metadata = serde_json::json!({
"source": "routine",
"routine_name": routine_name,
"status": status.to_string(),
"owner_id": owner_id,
"notify_user": notify.user,
"notify_channel": notify.channel,
});
if let Some(jid) = job_id {
metadata["job_id"] = serde_json::json!(jid.to_string());
}
let response = OutgoingResponse {
content: message,
thread_id: thread_id.map(String::from),
attachments: Vec::new(),
metadata,
metadata: serde_json::json!({
"source": "routine",
"routine_name": routine_name,
"status": status.to_string(),
"owner_id": owner_id,
"notify_user": notify.user,
"notify_channel": notify.channel,
}),
};
if let Err(e) = tx.send(response).await {
@@ -1965,6 +2034,7 @@ fn truncate(s: &str, max: usize) -> String {
/// 2. Strip HTML tags to prevent injection in web-rendered notifications
/// 3. Collapse multiple whitespace/newlines to single spaces for cleaner output
/// 4. Truncate to 500 chars to prevent oversized notifications
#[cfg(test)]
fn sanitize_summary(s: &str) -> String {
// Strip control characters (keep newline for now, collapse later)
let no_control: String = s
@@ -1991,59 +2061,19 @@ fn sanitize_summary(s: &str) -> String {
}
}
/// Remove actual HTML tags from a string while preserving non-HTML angle brackets.
///
/// Only strips patterns that look like real HTML/XML tags (e.g. `<div>`, `</p>`,
/// `<img src=...>`), not generic angle-bracket content like `Vec<String>`,
/// `cat < input.txt`, or comparison operators.
///
/// Also strips HTML comments (`<!--...-->`), SVG/MathML tags, and custom elements
/// (tags containing hyphens like `<custom-element>`).
/// Remove HTML/XML tags from a string.
#[cfg(test)]
fn strip_html_tags(s: &str) -> String {
use std::sync::LazyLock;
// HTML comment pattern: <!--...-->
static COMMENT_RE: LazyLock<Option<Regex>> =
LazyLock::new(|| Regex::new(r"<!--[\s\S]*?-->").ok());
// Known HTML/SVG/MathML tag names. Includes SVG tags (svg, path, circle, etc.)
// and MathML tags (math, mrow, etc.) that can carry event handlers.
static HTML_TAG_RE: LazyLock<Option<Regex>> = LazyLock::new(|| {
let tags = "a|abbr|address|area|article|aside|audio|b|base|bdi|bdo|blockquote|\
body|br|button|canvas|caption|cite|code|col|colgroup|data|datalist|dd|del|\
details|dfn|dialog|div|dl|dt|em|embed|fieldset|figcaption|figure|footer|\
form|h[1-6]|head|header|hgroup|hr|html|i|iframe|img|input|ins|kbd|label|\
legend|li|link|main|map|mark|meta|meter|nav|noscript|object|ol|optgroup|\
option|output|p|param|picture|pre|progress|q|rp|rt|ruby|s|samp|script|\
section|select|slot|small|source|span|strong|style|sub|summary|sup|table|\
tbody|td|template|textarea|tfoot|th|thead|time|title|tr|track|u|ul|var|\
video|wbr|\
svg|g|path|circle|ellipse|line|polyline|polygon|rect|text|tspan|defs|\
clippath|mask|pattern|image|use|symbol|marker|lineargradient|\
radialgradient|stop|filter|foreignobject|animate|animatetransform|\
math|mrow|mi|mo|mn|ms|mtext|mfrac|msqrt|mroot|msub|msup|msubsup|\
munder|mover|munderover|mtable|mtr|mtd|mspace|mpadded|mfenced|menclose";
// Handles: <tag>, </tag>, <tag/>, <tag />, <tag attr="val">, <tag attr="val"/>
Regex::new(&format!(r"(?i)</?(?:{})(?:\s[^>]*)?\s*/?>", tags)).ok()
});
// Custom elements: tags containing a hyphen (web components spec requires it).
// E.g. <custom-element>, <my-widget foo="bar">, </x-foo>
static CUSTOM_ELEMENT_RE: LazyLock<Option<Regex>> =
LazyLock::new(|| Regex::new(r"(?i)</?\w+-[\w-]*(?:\s[^>]*)?\s*/?>").ok());
let mut result = s.to_string();
if let Some(re) = COMMENT_RE.as_ref() {
result = re.replace_all(&result, "").into_owned();
let mut result = String::with_capacity(s.len());
let mut in_tag = false;
for c in s.chars() {
match c {
'<' => in_tag = true,
'>' if in_tag => in_tag = false,
_ if !in_tag => result.push(c),
_ => {}
}
}
if let Some(re) = HTML_TAG_RE.as_ref() {
result = re.replace_all(&result, "").into_owned();
}
if let Some(re) = CUSTOM_ELEMENT_RE.as_ref() {
result = re.replace_all(&result, "").into_owned();
}
result
}
@@ -2583,6 +2613,104 @@ mod tests {
}
}
/// Regression test for #1320: transient errors are retried for lightweight
/// routines but not for full-job routines or hard failures.
#[test]
fn test_retry_classification_for_routine_errors() {
use crate::error::RoutineError;
// Transient errors (retryable for lightweight routines)
let transient_errors: Vec<RoutineError> = vec![
RoutineError::LlmFailed {
reason: "rate limit".into(),
partial_tokens: None,
retryable: true,
},
RoutineError::LlmFailed {
reason: "network timeout".into(),
partial_tokens: Some(42),
retryable: true,
},
RoutineError::EmptyResponse {
partial_tokens: None,
},
RoutineError::TruncatedResponse {
partial_tokens: Some(100),
},
];
for err in &transient_errors {
assert!(err.is_retryable(), "{} should be retryable", err);
}
// Permanent LLM failures that should NOT be retried
// (retryable: false is set at conversion time by llm::retry::is_retryable)
let permanent_llm_errors: Vec<RoutineError> = vec![
RoutineError::LlmFailed {
reason: "Authentication failed for provider openai".into(),
partial_tokens: None,
retryable: false,
},
RoutineError::LlmFailed {
reason: "invalid_api_key: bad key".into(),
partial_tokens: None,
retryable: false,
},
RoutineError::LlmFailed {
reason: "content policy violation".into(),
partial_tokens: None,
retryable: false,
},
RoutineError::LlmFailed {
reason: "content_filter triggered".into(),
partial_tokens: None,
retryable: false,
},
RoutineError::LlmFailed {
reason: "context length exceeded: 150000 tokens used, 128000 allowed".into(),
partial_tokens: Some(100),
retryable: false,
},
RoutineError::LlmFailed {
reason: "model not available on provider anthropic".into(),
partial_tokens: None,
retryable: false,
},
RoutineError::LlmFailed {
reason: "content moderation flagged".into(),
partial_tokens: None,
retryable: false,
},
];
for err in &permanent_llm_errors {
assert!(!err.is_retryable(), "{} should NOT be retryable", err);
}
// Hard failures (never retried)
let hard_errors: Vec<RoutineError> = vec![
RoutineError::Disabled {
name: "test".into(),
},
RoutineError::NotFound {
id: uuid::Uuid::new_v4(),
},
RoutineError::NotAuthorized {
id: uuid::Uuid::new_v4(),
},
RoutineError::MaxConcurrent {
name: "test".into(),
},
RoutineError::JobDispatchFailed {
reason: "no docker".into(),
},
RoutineError::Database {
reason: "connection refused".into(),
},
];
for err in &hard_errors {
assert!(!err.is_retryable(), "{} should NOT be retryable", err);
}
}
#[test]
fn test_sanitize_summary_strips_control_chars() {
use super::sanitize_summary;
@@ -2618,33 +2746,6 @@ mod tests {
assert_eq!(sanitize_summary("<img src=x onerror=alert(1)>"), "");
}
#[test]
fn test_sanitize_summary_preserves_non_html_angle_brackets() {
use super::sanitize_summary;
// Rust/Java generics must pass through unchanged
assert_eq!(
sanitize_summary("expected Vec<String>"),
"expected Vec<String>"
);
assert_eq!(
sanitize_summary("HashMap<String, Vec<u8>>"),
"HashMap<String, Vec<u8>>"
);
// Shell redirects must pass through unchanged
assert_eq!(sanitize_summary("cat < input.txt"), "cat < input.txt");
// Comparison operators must pass through unchanged
assert_eq!(sanitize_summary("x < 10 && y > 20"), "x < 10 && y > 20");
// Mixed: real HTML stripped but generics preserved
assert_eq!(
sanitize_summary("Error in Vec<String>: <b>failed</b>"),
"Error in Vec<String>: failed"
);
}
#[test]
fn test_sanitize_summary_multibyte_truncation() {
use super::sanitize_summary;
@@ -2655,208 +2756,4 @@ mod tests {
assert!(result.len() <= 503);
assert!(result.ends_with("..."));
}
#[test]
fn test_sanitize_summary_truncates_long_text() {
use super::sanitize_summary;
let short = "This is a short summary.";
assert_eq!(sanitize_summary(short), short);
let long = "x".repeat(600);
let result = sanitize_summary(&long);
assert!(
result.len() <= 503,
"Truncated summary should be at most 503 bytes (500 + '...')"
);
assert!(
result.ends_with("..."),
"Truncated summary should end with ellipsis"
);
}
#[test]
fn test_sanitize_summary_strips_all_html_forms() {
use super::sanitize_summary;
// Self-closing tags without whitespace: <br/>, <img/>
assert_eq!(sanitize_summary("line1<br/>line2"), "line1line2");
assert_eq!(sanitize_summary("text<img/>more"), "textmore");
assert_eq!(sanitize_summary("text<br />more"), "textmore");
// HTML comments
assert_eq!(sanitize_summary("before<!--x-->after"), "beforeafter");
assert_eq!(sanitize_summary("a<!-- multi\nline -->b"), "ab");
// SVG tags (can carry event handlers)
assert_eq!(
sanitize_summary("<svg onload=alert(1)>payload</svg>"),
"payload"
);
assert_eq!(sanitize_summary("<svg><circle r=10/></svg>"), "");
// MathML tags
assert_eq!(sanitize_summary("<math><mrow>x</mrow></math>"), "x");
// Custom elements (web components with hyphens)
assert_eq!(
sanitize_summary("before<custom-element>inner</custom-element>after"),
"beforeinnerafter"
);
assert_eq!(
sanitize_summary("<my-widget foo=\"bar\">content</my-widget>"),
"content"
);
// Generics must still be preserved
assert_eq!(
sanitize_summary("expected Vec<String>"),
"expected Vec<String>"
);
}
#[test]
fn test_status_display_label_readable() {
use super::status_display_label;
assert_eq!(status_display_label(RunStatus::Ok), "Completed");
assert_eq!(status_display_label(RunStatus::Failed), "Failed");
assert_eq!(
status_display_label(RunStatus::Attention),
"Needs attention"
);
assert_eq!(status_display_label(RunStatus::Running), "Running");
}
#[tokio::test]
async fn test_notification_message_uses_readable_status() {
use tokio::sync::mpsc;
let (tx, mut rx) = mpsc::channel(1);
let notify = NotifyConfig {
on_success: true,
on_failure: true,
on_attention: true,
..Default::default()
};
super::send_notification(
&tx,
&notify,
"user-1",
"my-routine",
RunStatus::Ok,
Some("All good"),
None,
None,
)
.await;
let msg = rx.recv().await.expect("should receive notification");
assert!(
msg.content.contains("Completed"),
"Notification should use readable label 'Completed', got: {}",
msg.content
);
assert!(
!msg.content.contains(": ok"),
"Notification should not contain raw lowercase status"
);
}
#[tokio::test]
async fn test_notification_includes_job_id_in_metadata() {
use tokio::sync::mpsc;
let (tx, mut rx) = mpsc::channel(1);
let notify = NotifyConfig {
on_failure: true,
..Default::default()
};
let job_id = uuid::Uuid::new_v4();
super::send_notification(
&tx,
&notify,
"user-1",
"my-routine",
RunStatus::Failed,
Some("something broke"),
None,
Some(job_id),
)
.await;
let msg = rx.recv().await.expect("should receive notification");
let meta_job_id = msg.metadata["job_id"]
.as_str()
.expect("metadata should contain job_id");
assert_eq!(meta_job_id, job_id.to_string());
}
#[tokio::test]
async fn test_notification_omits_job_id_when_none() {
use tokio::sync::mpsc;
let (tx, mut rx) = mpsc::channel(1);
let notify = NotifyConfig {
on_success: true,
..Default::default()
};
super::send_notification(
&tx,
&notify,
"user-1",
"my-routine",
RunStatus::Ok,
Some("done"),
None,
None,
)
.await;
let msg = rx.recv().await.expect("should receive notification");
assert!(
msg.metadata.get("job_id").is_none(),
"metadata should not contain job_id when None"
);
}
#[tokio::test]
async fn test_notification_truncates_long_summary() {
use tokio::sync::mpsc;
let (tx, mut rx) = mpsc::channel(1);
let notify = NotifyConfig {
on_failure: true,
..Default::default()
};
let long_summary = "z".repeat(1000);
super::send_notification(
&tx,
&notify,
"user-1",
"my-routine",
RunStatus::Failed,
Some(&long_summary),
None,
None,
)
.await;
let msg = rx.recv().await.expect("should receive notification");
// The sanitized summary should be truncated to ~500 chars + "..."
// The full message includes icon + routine name + label, so just check
// it doesn't contain the full 1000-char string.
assert!(
!msg.content.contains(&long_summary),
"Notification should truncate long summaries"
);
assert!(
msg.content.contains("..."),
"Truncated notification should contain ellipsis"
);
}
}
+129 -3
View File
@@ -395,16 +395,49 @@ pub enum RoutineError {
Database { reason: String },
#[error("LLM call failed: {reason}")]
LlmFailed { reason: String },
LlmFailed {
reason: String,
/// Partial token count consumed before the failure (if any).
/// Used to accumulate usage across retry attempts.
partial_tokens: Option<i32>,
/// Whether the underlying LLM error was classified as retryable.
/// Set at the `LlmError` → `RoutineError` conversion site using
/// `crate::llm::retry::is_retryable()`, avoiding fragile substring
/// matching on the stringified reason.
retryable: bool,
},
#[error("Failed to dispatch full job: {reason}")]
JobDispatchFailed { reason: String },
#[error("LLM returned empty content")]
EmptyResponse,
EmptyResponse {
/// Tokens consumed by the call that produced the empty response.
partial_tokens: Option<i32>,
},
#[error("LLM response truncated (finish_reason=length) with no content")]
TruncatedResponse,
TruncatedResponse {
/// Tokens consumed by the call that produced the truncated response.
partial_tokens: Option<i32>,
},
}
impl RoutineError {
/// Whether this error is transient and worth retrying with backoff.
///
/// Retryable: LLM failures where the underlying `LlmError` was classified
/// as retryable by `crate::llm::retry::is_retryable()`, empty responses,
/// and truncated responses.
/// Non-retryable: configuration errors, authorization, resource limits,
/// DB errors, and LLM failures caused by auth/content-policy/context-length.
pub fn is_retryable(&self) -> bool {
match self {
RoutineError::LlmFailed { retryable, .. } => *retryable,
RoutineError::EmptyResponse { .. } | RoutineError::TruncatedResponse { .. } => true,
_ => false,
}
}
}
/// Result type alias for the agent.
@@ -514,6 +547,99 @@ mod tests {
assert!(msg.contains("bad format"), "Should mention reason: {msg}");
}
#[test]
fn routine_error_retryable_classification() {
// Transient errors should be retryable
assert!(
RoutineError::LlmFailed {
reason: "timeout".into(),
partial_tokens: None,
retryable: true,
}
.is_retryable()
);
// Non-retryable LLM error
assert!(
!RoutineError::LlmFailed {
reason: "timeout".into(),
partial_tokens: None,
retryable: false,
}
.is_retryable()
);
assert!(
RoutineError::EmptyResponse {
partial_tokens: None
}
.is_retryable()
);
assert!(
RoutineError::TruncatedResponse {
partial_tokens: None
}
.is_retryable()
);
// Hard failures should NOT be retryable
assert!(
!RoutineError::Disabled {
name: "test".into()
}
.is_retryable()
);
assert!(
!RoutineError::JobDispatchFailed {
reason: "no docker".into()
}
.is_retryable()
);
assert!(
!RoutineError::Database {
reason: "conn refused".into()
}
.is_retryable()
);
assert!(!RoutineError::NotFound { id: Uuid::new_v4() }.is_retryable());
assert!(!RoutineError::NotAuthorized { id: Uuid::new_v4() }.is_retryable());
assert!(
!RoutineError::MaxConcurrent {
name: "test".into()
}
.is_retryable()
);
assert!(
!RoutineError::UnknownTriggerType {
trigger_type: "x".into()
}
.is_retryable()
);
assert!(
!RoutineError::UnknownActionType {
action_type: "x".into()
}
.is_retryable()
);
assert!(
!RoutineError::MissingField {
context: "c".into(),
field: "f".into()
}
.is_retryable()
);
assert!(
!RoutineError::InvalidCron {
reason: "bad".into()
}
.is_retryable()
);
assert!(
!RoutineError::UnknownRunStatus {
status: "bad".into()
}
.is_retryable()
);
}
#[test]
fn top_level_error_from_conversions() {
let config_err = ConfigError::MissingEnvVar("TEST".to_string());