Synced from monorepo

Changes:
- Non-blocking coding-data sharing upsell banner
- Consolidate remediation in Doctor
- Auto mode defers fail-closed gate asks to the classifier
- Coalesce marketplace list fetches
- Allow removing a marketplace source by name
- Contain hung git marketplace sources (timeouts, non-blocking refresh, unbrick modal)
- Label failed workspace RPCs with error_kind
- Drop redundant explicit tonic/prost deps from xai-grok-shell
- Report real exit codes for completed background shells
- Narrow the date-rollover reminder to date-bearing templates
- Wire toolOverrides through the session and agent
- Security: Bash(git:*) allowlist matches whole command chain by prefix
- Split prompt-trigger telemetry and record classifier provenance
- Raise connectors-manager timeout to 60s
- Auto classifier honors recorded approvals for repeat actions
- Apply doctor fixes in the TUI
- Auto-mode classifier timeouts prompt instead of silently denying
- Scope subagent completion drains to the owning session
- Add the toolOverrides wire types
- Set client_identifier=grok-agent-sdk
- Accept both spellings of the workspace-teleport kill switch
- Persist one-shot occurrence journal
- Stop turns that poll the exact same tool call 16x in a row
- Copy compaction checkpoint files when forking sessions
- Auto-focus permission prompt from scrollback
- Esc cancels the running turn in non-vim and minimal modes
- List Ctrl+Z undo and redo in keyboard shortcuts
- Out-of-process macOS mic capture
- Show active auth mode on session-info
- Install the npm binary under $GROK_HOME
- Remove hover/click dead zones between dashboard items
- Route startup warnings to doctor
- Document [feedback.user] author identity config
- Extend bang command timeout
- Close combine-queued edit-hold race
- Integrate relocation recovery
- Expose privacy notice rollout flag
- Break harness discovery ref cycle so connections can idle-evict
- Shift/Alt+Enter inserts newline when editing a queued prompt
- Gate project Claude permissions on folder trust
- Echo response.create.event_id on response.created
- Toast when session creation fails from disk full
- Add shared test process lifecycle
- Enable dynamic workflows by default
- Add relocation transaction state machine
- Add shared test sandbox
- Surface auth failures on model-switch compact
- Persist durable scheduler expiry
- Confirm before removing extensions-modal items
- Re-run compact and prompt after login when compact hit expired auth
- Recap sends hosted tools under backend search
This commit is contained in:
grokkybara[bot] 2026-07-22 19:18:53 +01:00
commit a5727c5960
482 changed files with 37627 additions and 13402 deletions

View file

@ -246,6 +246,12 @@ impl crate::types::resources::ResourceType for BashParams {
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// Product default advertised in the model-facing schema (FG). Not applied as a
/// serde default: omit/`None` must remain "use host/FG policy, BG unbounded".
fn schema_default_timeout_ms() -> Option<u64> {
Some(120_000)
}
/// Input for the bash/terminal command tool.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct BashToolInput {
@ -258,11 +264,14 @@ pub struct BashToolInput {
/// the task runs until it exits or is killed via the kill task tool.
// keep in sync with the rustdoc above
#[schemars(
description = "Optional timeout in milliseconds (max 300000). Default: 120000 (2 minutes). `timeout: 0` in background mode disables the wrapper timeout entirely; the task runs until it exits or is killed via the kill task tool."
description = "Optional timeout in milliseconds (max 300000). Default: 120000 (2 minutes). `timeout: 0` in background mode disables the wrapper timeout entirely; the task runs until it exits or is killed via the kill task tool.",
default = "schema_default_timeout_ms"
)]
// Some models serialize numeric tool args
// as JSON strings (`"120000"`), which a plain `Option<u64>` rejects. Accept
// string-or-number here; the schema still advertises an integer.
// Serde default stays None so omit ≠ Some(120000): background omit must stay
// unbounded (see resolve_effective_timeout). Schema still advertises 120000.
#[serde(
default,
deserialize_with = "crate::types::schema::deserialize_lenient_u64",
@ -2276,6 +2285,34 @@ impl xai_tool_runtime::Tool for BashTool {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bash_timeout_schema_defaults_to_120s() {
let schema = serde_json::to_value(schemars::schema_for!(BashToolInput)).unwrap();
let timeout = &schema["properties"]["timeout"];
assert_eq!(
timeout.get("default"),
Some(&serde_json::json!(120_000)),
"timeout schema should advertise default 120000, got {timeout}"
);
// Serde omit stays None so background without timeout remains unbounded.
let missing: BashToolInput =
serde_json::from_str(r#"{"command":"ls","description":"list"}"#).unwrap();
assert_eq!(missing.timeout, None);
let zero: BashToolInput =
serde_json::from_str(r#"{"command":"ls","description":"list","timeout":0}"#).unwrap();
assert_eq!(zero.timeout, Some(0));
// Explicit BG omit still resolves unbounded.
assert_eq!(
BashTool::resolve_effective_timeout(
missing.timeout,
true,
DEFAULT_TIMEOUT,
DEFAULT_MAX_TIMEOUT_MS,
),
std::time::Duration::MAX
);
}
use crate::computer::types::{
BackgroundHandle, ComputerError, KillOutcome, TaskSnapshot, TerminalBackend,
TerminalRunRequest, TerminalRunResult,
@ -2288,8 +2325,7 @@ mod tests {
/// Models occasionally serialize numeric tool args as JSON strings. The
/// `timeout` field must accept both `120000` and `"120000"`, stay `None`
/// when omitted or null. Regression for the `invalid type: string
/// "120000", expected u64` failure seen with some models.
/// when omitted or null (FG host policy / BG unbounded).
#[test]
fn timeout_accepts_string_or_integer() {
let from_int: BashToolInput =

View file

@ -93,16 +93,13 @@ pub struct GrepSearchInput {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<usize>,
#[schemars(
rename = "-i",
description = "Case insensitive search (rg -i). Defaults to false."
)]
#[schemars(rename = "-i", description = "Case insensitive search (rg -i).")]
#[serde(
rename = "-i",
default,
deserialize_with = "crate::types::schema::deserialize_lenient_option_bool"
deserialize_with = "crate::types::schema::deserialize_lenient_bool"
)]
pub case_insensitive: Option<bool>,
pub case_insensitive: bool,
#[schemars(
description = "File type to search (rg --type). Common types: js, py, rust, go, java, etc. More efficient than glob for standard file types."
@ -117,14 +114,13 @@ pub struct GrepSearchInput {
pub head_limit: Option<usize>,
#[schemars(
description = "Enable multiline mode where . matches newlines and patterns can span lines (rg -U --multiline-dotall). Default: false."
description = "Enable multiline mode where . matches newlines and patterns can span lines (rg -U --multiline-dotall)."
)]
#[serde(
default,
deserialize_with = "crate::types::schema::deserialize_lenient_option_bool",
skip_serializing_if = "Option::is_none"
deserialize_with = "crate::types::schema::deserialize_lenient_bool"
)]
pub multiline: Option<bool>,
pub multiline: bool,
}
// ───────────────────────────────────────────────────────────────────────────
@ -766,7 +762,7 @@ async fn prepare_grep(
.arg("1000")
.arg("--max-columns-preview");
if input.case_insensitive.unwrap_or(false) {
if input.case_insensitive {
cmd.arg("--ignore-case");
}
@ -792,7 +788,7 @@ async fn prepare_grep(
cmd.arg("--type").arg(t);
}
if input.multiline.unwrap_or(false) {
if input.multiline {
cmd.arg("-U").arg("--multiline-dotall");
}
@ -1483,13 +1479,55 @@ mod tests {
before_context: None,
after_context: None,
context: None,
case_insensitive: None,
case_insensitive: false,
r#type: None,
head_limit: None,
multiline: None,
multiline: false,
}
}
/// Boolean flags must be non-optional in the model-facing schema so the
/// default is unambiguous (`false`, not `null` + "Default: false" prose).
#[test]
fn grep_bool_flags_schema_is_plain_boolean_with_default_false() {
let schema = serde_json::to_value(schemars::schema_for!(GrepSearchInput)).unwrap();
let props = &schema["properties"];
// Field is renamed to "-i" for the model-facing name.
let case = &props["-i"];
assert_eq!(case["type"], "boolean", "case_insensitive schema: {case}");
assert_eq!(case["default"], false, "case_insensitive schema: {case}");
assert!(
case.get("anyOf").is_none(),
"must not use nullable anyOf: {case}"
);
let multi = &props["multiline"];
assert_eq!(multi["type"], "boolean", "multiline schema: {multi}");
assert_eq!(multi["default"], false, "multiline schema: {multi}");
assert!(
multi.get("anyOf").is_none(),
"must not use nullable anyOf: {multi}"
);
}
#[test]
fn grep_bool_flags_deserialize_missing_and_null_as_false() {
let missing: GrepSearchInput = serde_json::from_str(r#"{"pattern":"foo"}"#).unwrap();
assert!(!missing.case_insensitive);
assert!(!missing.multiline);
let nulls: GrepSearchInput =
serde_json::from_str(r#"{"pattern":"foo","-i":null,"multiline":null}"#).unwrap();
assert!(!nulls.case_insensitive);
assert!(!nulls.multiline);
let truths: GrepSearchInput =
serde_json::from_str(r#"{"pattern":"foo","-i":"yes","multiline":1}"#).unwrap();
assert!(truths.case_insensitive);
assert!(truths.multiline);
}
#[test]
fn grep_timeout_secs_platform_defaults() {
assert_eq!(grep_timeout_secs(false), 20);
@ -2049,10 +2087,10 @@ mod tests {
before_context: None,
after_context: None,
context: None,
case_insensitive: None,
case_insensitive: false,
r#type: None,
head_limit: None,
multiline: None,
multiline: false,
}
},
)
@ -2087,10 +2125,10 @@ mod tests {
before_context: None,
after_context: None,
context: None,
case_insensitive: None,
case_insensitive: false,
r#type: None,
head_limit: None,
multiline: None,
multiline: false,
}
},
)
@ -2123,10 +2161,10 @@ mod tests {
before_context: None,
after_context: None,
context: None,
case_insensitive: None,
case_insensitive: false,
r#type: None,
head_limit: None,
multiline: None,
multiline: false,
},
)
.await

View file

@ -29,6 +29,10 @@ pub const MAX_TIMEOUT_MS: u64 = 36_000_000; // 10 hours
/// Max result size for the tool_result response.
pub const MAX_RESULT_SIZE_CHARS: usize = 10_000;
fn default_timeout_ms() -> Option<u64> {
Some(DEFAULT_TIMEOUT_MS)
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct MonitorInput {
/// Shell command or script. Each stdout line is an event; exit ends the watch.
@ -45,9 +49,10 @@ pub struct MonitorInput {
/// Kill the monitor after this deadline (ms). Ignored when persistent is true.
/// Default: 36000000 (10 hr). Max: 36000000 (10 hr).
#[serde(default)]
#[serde(default = "default_timeout_ms")]
#[schemars(
description = "Kill the monitor after this deadline (ms). Default: 36000000 (10 hr)."
description = "Kill the monitor after this deadline (ms). Default: 36000000 (10 hr). Max: 36000000 (10 hr).",
default = "default_timeout_ms"
)]
pub timeout_ms: Option<u64>,
@ -55,12 +60,12 @@ pub struct MonitorInput {
/// Stop with kill_command_or_subagent.
#[serde(
default,
deserialize_with = "crate::types::schema::deserialize_lenient_option_bool"
deserialize_with = "crate::types::schema::deserialize_lenient_bool"
)]
#[schemars(
description = "Run for the lifetime of the session (no timeout).${%- if tools.by_kind.kill_task_action %} Stop with ${{ tools.by_kind.kill_task_action }}.${%- endif %}"
)]
pub persistent: Option<bool>,
pub persistent: bool,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
@ -85,7 +90,7 @@ pub enum MonitorError {
impl MonitorInput {
/// Validate input constraints.
pub fn validate(&self) -> Result<(), MonitorError> {
let persistent = self.persistent.unwrap_or(false);
let persistent = self.persistent;
if let Some(timeout) = self.timeout_ms
&& !persistent
&& timeout > MAX_TIMEOUT_MS
@ -97,7 +102,7 @@ impl MonitorInput {
/// Resolved timeout in milliseconds (0 for persistent / no-deadline monitors).
pub fn resolved_timeout_ms(&self) -> u64 {
if self.persistent.unwrap_or(false) {
if self.persistent {
0
} else {
self.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS)
@ -115,7 +120,7 @@ mod tests {
command: "tail -f log".into(),
description: "watch log".into(),
timeout_ms: None,
persistent: None,
persistent: false,
};
assert_eq!(input.resolved_timeout_ms(), DEFAULT_TIMEOUT_MS);
assert!(input.validate().is_ok());
@ -128,7 +133,7 @@ mod tests {
command: "tail -f log".into(),
description: "watch log".into(),
timeout_ms: None,
persistent: Some(true),
persistent: true,
};
assert_eq!(input.resolved_timeout_ms(), 0);
assert!(input.validate().is_ok());
@ -140,7 +145,7 @@ mod tests {
command: "cmd".into(),
description: "desc".into(),
timeout_ms: Some(600_000),
persistent: None,
persistent: false,
};
assert_eq!(input.resolved_timeout_ms(), 600_000);
assert!(input.validate().is_ok());
@ -152,7 +157,7 @@ mod tests {
command: "cmd".into(),
description: "desc".into(),
timeout_ms: Some(MAX_TIMEOUT_MS + 1),
persistent: Some(false),
persistent: false,
};
assert!(input.validate().is_err());
}
@ -163,7 +168,7 @@ mod tests {
command: "cmd".into(),
description: "desc".into(),
timeout_ms: Some(MAX_TIMEOUT_MS + 1),
persistent: Some(true),
persistent: true,
};
assert!(input.validate().is_ok());
}

View file

@ -108,6 +108,10 @@ Usage:
- Results are returned with line numbers starting at 1. The format is: LINE_NUMBERLINE_CONTENT
- This tool can read PDF files (.pdf), PowerPoint files (.pptx), Jupyter notebooks (.ipynb files), and image files (e.g. PNG, JPG, etc).
- When reading an image file the contents are presented visually as this tool uses multimodal LLMs."#;
/// Schema-only advertised default (runtime still treats omit as line 1 via unwrap_or).
fn schema_default_offset() -> Option<i64> {
Some(1)
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct ReadFileInput {
#[serde(rename = "target_file")]
@ -122,6 +126,7 @@ pub struct ReadFileInput {
)]
#[schemars(
with = "GrokIntegerSchema",
default = "schema_default_offset",
description = "The line number to start reading from. Only provide if the file is too large to read at once."
)]
pub offset: Option<i64>,
@ -444,9 +449,10 @@ pub(crate) async fn run_read_file(
}
if crate::util::binary::is_binary(&extension, &file_bytes) {
tracing::info!(
path = % path.display(), extension = % extension, detected_by = if crate
::util::binary::BINARY_EXTENSIONS.binary_search(& extension.as_str()).is_ok()
{ "extension" } else { "content_inspection" },
path = %path.display(),
extension = %extension,
detected_by = if crate::util::binary::BINARY_EXTENSIONS
.binary_search(&extension.as_str()).is_ok() { "extension" } else { "content_inspection" },
"binary file rejected by read_file"
);
return Ok(ReadFileOutput::FileReadError(format!(
@ -625,26 +631,51 @@ impl xai_tool_runtime::Tool for ReadFileTool {
let Some(spec) = admitted_spec else {
let this = ReadFileTool;
return Box::pin(async_stream::stream! {
yield xai_tool_runtime::ToolStreamItem::Terminal(this.run(ctx, input)
. await);
yield xai_tool_runtime::ToolStreamItem::Terminal(this.run(ctx, input).await);
});
};
Box::pin(async_stream::stream! {
match ReadFileTool::read_with_streamability(& ctx, input). await {
Ok((output, streamable)) => { if streamable && let
ReadFileOutput::FileContent(fc) = & output && ! fc.content.is_empty() {
let content = fc.content.as_bytes(); let mut last_total : u64 = 0; let
mut window_start = 0usize; while window_start < content.len() { let mut
window_end = (window_start + STREAM_DELTA_TARGET_BYTES).min(content
.len()); while window_end > window_start && ! fc.content
.is_char_boundary(window_end) { window_end -= 1; }
if let Some(p) =
xai_tool_runtime::stream_chunk(spec, & content[..window_end], window_end
as u64, & mut last_total, false,) { yield
xai_tool_runtime::ToolStreamItem::Progress(p); } window_start =
window_end; } } yield
xai_tool_runtime::ToolStreamItem::Terminal(Ok(output)); } Err(e) => yield
xai_tool_runtime::ToolStreamItem::Terminal(Err(e)), }
// `streamable` is call-local to this read.
match ReadFileTool::read_with_streamability(&ctx, input).await {
Ok((output, streamable)) => {
if streamable
&& let ReadFileOutput::FileContent(fc) = &output
&& !fc.content.is_empty()
{
// Replay char-aligned slices of the final `content`
// (each below the 16 KiB cap; see
// STREAM_DELTA_TARGET_BYTES).
let content = fc.content.as_bytes();
let mut last_total: u64 = 0;
let mut window_start = 0usize;
while window_start < content.len() {
let mut window_end =
(window_start + STREAM_DELTA_TARGET_BYTES).min(content.len());
// Align DOWN to a char boundary (a char is ≤ 4
// bytes vs the 4 KiB target: never a zero-width
// window).
while window_end > window_start
&& !fc.content.is_char_boundary(window_end)
{
window_end -= 1;
}
if let Some(p) = xai_tool_runtime::stream_chunk(
spec,
&content[..window_end],
window_end as u64,
&mut last_total,
// Full replay, no streaming loss ⇒ never truncated.
false,
) {
yield xai_tool_runtime::ToolStreamItem::Progress(p);
}
window_start = window_end;
}
}
yield xai_tool_runtime::ToolStreamItem::Terminal(Ok(output));
}
Err(e) => yield xai_tool_runtime::ToolStreamItem::Terminal(Err(e)),
}
})
}
#[tracing::instrument(name = "tool.read_file", skip_all, fields(path = %input.path))]
@ -2385,12 +2416,17 @@ pub fn verify(req: &HttpRequest) -> Result<Claims, Error> {
}
}
#[test]
fn read_file_offset_description_unchanged() {
fn read_file_offset_schema_advertises_start_default() {
let src = include_str!("mod.rs");
assert!(
src
.contains("description = \"The line number to start reading from. Only provide if the file is too large to read at once.\""),
"offset schemars description must not change"
src.contains(
"description = \"The line number to start reading from. Only provide if the file is too large to read at once.\""
),
"offset schemars description must remain the pre-PR wording"
);
assert!(
src.contains("default = \"schema_default_offset\""),
"offset must advertise schema_default_offset"
);
}
#[test]

View file

@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::time::Duration;
use chrono::Utc;
@ -31,6 +32,12 @@ enum LoopFireOutcome {
Skipped,
}
enum ExpiryPersistenceOutcome {
Committed,
NotCommitted(std::io::Error),
Unknown(SchedulerError),
}
pub(crate) struct PendingDurableRemoval {
task_id: String,
reservation: super::types::SchedulerReservation,
@ -97,6 +104,7 @@ pub struct SchedulerActor {
pub(crate) cancel_token: CancellationToken,
pub(crate) clock: SchedulerClock,
pub(crate) pending_removal: Option<PendingDurableRemoval>,
pub(crate) blocked_expiries: HashSet<String>,
}
impl SchedulerActor {
@ -141,11 +149,18 @@ impl SchedulerActor {
async fn complete_pending_removal(&mut self) -> Result<bool, SchedulerError> {
if self.notification_handle.durable_targets() == DurableNotificationTargets::None {
// Immutable targets cannot recover; abandon only the uncommitted reservation.
self.pending_removal = None;
// Targets are immutable, so retaining the reservation would wedge all later commands.
let task_id = self
.pending_removal
.take()
.expect("pending durable removal exists")
.task_id;
tracing::error!(%task_id, "Durable scheduler removal unavailable");
return Err(SchedulerError::NoDurableNotificationConsumer);
}
self.persist_resources().await?;
let (task_id, version) = {
let pending = self
.pending_removal
@ -188,13 +203,11 @@ impl SchedulerActor {
}
}
let task_ids = {
let mut res = self.resources.lock().await;
res.get_or_default::<State<SchedulerState>>()
.tasks
.drain(..)
.map(|task| task.id)
.collect::<Vec<_>>()
let task_ids: Vec<String> = {
let res = self.resources.lock().await;
res.get::<State<SchedulerState>>()
.map(|state| state.tasks.iter().map(|task| task.id.clone()).collect())
.unwrap_or_default()
};
if task_ids.is_empty() {
return;
@ -246,7 +259,8 @@ impl SchedulerActor {
.map(|s| {
s.tasks
.iter()
.map(|t| t.next_fire_at())
.filter(|task| !self.blocked_expiries.contains(&task.id))
.map(ScheduledTask::next_fire_at)
.min()
.map(|next| {
let now = Utc::now();
@ -266,7 +280,9 @@ impl SchedulerActor {
let now = Utc::now();
let mut res = self.resources.lock().await;
let state = res.get_or_default::<State<SchedulerState>>();
let idx = state.tasks.iter().position(|t| t.next_fire_at() <= now);
let idx = state.tasks.iter().position(|task| {
task.next_fire_at() <= now && !self.blocked_expiries.contains(&task.id)
});
let Some(idx) = idx else {
return;
@ -278,6 +294,7 @@ impl SchedulerActor {
let should_remove = !task.recurring;
let prompt = task.prompt.clone();
let human_schedule = interval_to_human(task.interval_secs);
let is_durable = task.durable;
let foreground = task.foreground;
let last_subagent_id = task.last_subagent_id.clone();
let iterations_since_fresh = task.iterations_since_fresh;
@ -290,6 +307,89 @@ impl SchedulerActor {
1
};
let transition = if is_expired { "expiry" } else { "fire" };
if is_expired && is_durable {
if self.notification_handle.durable_targets() == DurableNotificationTargets::None {
tracing::error!(%task_id, "Durable scheduler expiry unavailable");
self.blocked_expiries.insert(task_id);
return;
}
let mut reservation = self.clock.prepare_transition(1);
let expired_task = state.tasks.remove(idx);
let acknowledgement = self
.resources_persistence
.enqueue_save_and_flush(res.serialize());
drop(res);
tracing::info!(task_id = %task_id, "Scheduled task expired; removing without firing");
let persistence = match acknowledgement {
Ok(acknowledgement) => {
let deadline = tokio::time::Instant::now() + DURABILITY_BARRIER_TIMEOUT;
tokio::select! {
_ = self.cancel_token.cancelled() => {
ExpiryPersistenceOutcome::Unknown(SchedulerError::Cancelled)
}
result = tokio::time::timeout_at(deadline, acknowledgement) => {
match result {
Ok(Ok(Ok(()))) => ExpiryPersistenceOutcome::Committed,
Ok(Ok(Err(error))) => {
ExpiryPersistenceOutcome::NotCommitted(error)
}
Ok(Err(_)) => ExpiryPersistenceOutcome::Unknown(
SchedulerError::Persistence(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"resources persistence writer dropped acknowledgement",
)),
),
Err(_) => {
ExpiryPersistenceOutcome::Unknown(SchedulerError::Timeout)
}
}
}
}
}
Err(error) => ExpiryPersistenceOutcome::NotCommitted(error),
};
match persistence {
ExpiryPersistenceOutcome::Committed => {}
ExpiryPersistenceOutcome::NotCommitted(error) => {
tracing::warn!(
%task_id,
%error,
"Durable scheduler expiry was not persisted"
);
let mut resources = self.resources.lock().await;
resources
.get_or_default::<State<SchedulerState>>()
.tasks
.insert(idx, expired_task);
self.blocked_expiries.insert(task_id);
return;
}
ExpiryPersistenceOutcome::Unknown(error) => {
tracing::warn!(
%task_id,
%error,
"Durable scheduler expiry persistence outcome is unknown"
);
return;
}
}
let version = reservation.version_at(0);
if let Err(error) = self.publish_durable_removal(task_id.clone(), version).await {
tracing::warn!(
%task_id,
%error,
"Failed to acknowledge durable scheduler expiry"
);
return;
}
let commit = reservation.commit_next(&mut self.clock);
log_rollover(transition, Some(&task_id), commit.rollover);
return;
}
let mut reservation = self.clock.prepare_transition(transition_count);
if is_expired {
@ -825,6 +925,7 @@ mod tests {
cancel_token: cancel_token.clone(),
clock: SchedulerClock::new(),
pending_removal: None,
blocked_expiries: HashSet::new(),
}
.run(),
);
@ -852,6 +953,22 @@ mod tests {
.expect("notification channel closed")
}
fn expired_task(id: &str, durable: bool) -> ScheduledTask {
let mut task = ScheduledTask::new(1, id.into(), true, durable);
task.id = id.into();
task.created_at = Utc::now() - chrono::Duration::seconds(10);
task.expires_at = Some(Utc::now() - chrono::Duration::seconds(1));
task.foreground = true;
task
}
fn due_one_shot(id: &str) -> ScheduledTask {
let mut task = ScheduledTask::new(1, id.into(), false, false);
task.id = id.into();
task.created_at = Utc::now() - chrono::Duration::seconds(10);
task
}
fn auto_acknowledged_notifications() -> (
ToolNotificationHandle,
mpsc::UnboundedReceiver<ToolNotification>,
@ -889,6 +1006,7 @@ mod tests {
cancel_token: CancellationToken::new(),
clock: SchedulerClock::at_revision_for_test(revision),
pending_removal: None,
blocked_expiries: HashSet::new(),
},
notifications,
)
@ -915,6 +1033,7 @@ mod tests {
cancel_token: cancel_token.clone(),
clock: SchedulerClock::new(),
pending_removal: None,
blocked_expiries: HashSet::new(),
};
tokio::spawn(actor.run());
@ -1232,6 +1351,7 @@ mod tests {
cancel_token: cancel_token.clone(),
clock: SchedulerClock::new(),
pending_removal: None,
blocked_expiries: HashSet::new(),
};
tokio::spawn(actor.run());
@ -1318,6 +1438,7 @@ mod tests {
cancel_token: cancel_token.clone(),
clock: SchedulerClock::new(),
pending_removal: None,
blocked_expiries: HashSet::new(),
};
tokio::spawn(actor.run());
@ -1523,7 +1644,7 @@ mod tests {
}
#[tokio::test]
async fn cancel_sends_removed_for_remaining_tasks_and_drains_state() {
async fn cancel_sends_removed_for_remaining_tasks_without_draining_state() {
let mut resources = Resources::new();
resources.register_state::<SchedulerState>();
@ -1548,6 +1669,7 @@ mod tests {
cancel_token: cancel_token.clone(),
clock: SchedulerClock::new(),
pending_removal: None,
blocked_expiries: HashSet::new(),
};
let handle = tokio::spawn(actor.run());
@ -1568,14 +1690,15 @@ mod tests {
}
removed_ids.sort();
assert_eq!(removed_ids, vec!["cancel-A", "cancel-B"]);
assert!(
assert_eq!(
shared
.lock()
.await
.get::<State<SchedulerState>>()
.unwrap()
.tasks
.is_empty()
.len(),
2
);
}
@ -1611,6 +1734,7 @@ mod tests {
cancel_token: cancel_token.clone(),
clock: SchedulerClock::at_revision_for_test(revision),
pending_removal: None,
blocked_expiries: HashSet::new(),
};
tokio::spawn(actor.run());
@ -2091,6 +2215,7 @@ mod tests {
cancel_token: cancel_token.clone(),
clock: SchedulerClock::new(),
pending_removal: None,
blocked_expiries: HashSet::new(),
}
.run(),
);
@ -2433,6 +2558,233 @@ mod tests {
);
}
#[tokio::test]
async fn durable_expiry_persists_before_ack_and_commits_version() {
let (persistence, mut saves) = crate::persistence::ResourcesPersistence::controlled();
let mut actor = make_boundary_actor(vec![expired_task("expired", true)], 0).0;
actor.resources_persistence = Arc::new(persistence);
let (notification_handle, mut notifications) =
ToolNotificationHandle::acknowledged_channel();
actor.notification_handle = notification_handle;
{
let expiry = actor.fire_next_task();
tokio::pin!(expiry);
let (snapshot, persisted) = tokio::select! {
_ = expiry.as_mut() => panic!("expiry must wait for resource persistence"),
save = next_event(&mut saves) => save,
};
assert_eq!(
snapshot["state"]["grok_build.Scheduler"]["tasks"],
serde_json::json!([])
);
assert!(notifications.try_recv().is_err());
persisted.send(Ok(())).unwrap();
let delivery = tokio::select! {
_ = expiry.as_mut() => panic!("expiry must wait for tombstone acknowledgement"),
delivery = next_acknowledged(&mut notifications) => delivery,
};
let removed = notification!(delivery.notification, ScheduledTaskRemoved);
assert_eq!(removed.revision, 1);
delivery.acknowledgement.unwrap().send(Ok(())).unwrap();
tokio::time::timeout(Duration::from_secs(1), expiry.as_mut())
.await
.unwrap();
}
assert_eq!(actor.clock.snapshot().revision(), 1);
assert!(
actor
.resources
.lock()
.await
.get::<State<SchedulerState>>()
.unwrap()
.tasks
.is_empty()
);
}
#[tokio::test]
async fn expiry_persistence_failure_restores_and_blocks_while_plain_task_fires() {
let (persistence, mut saves) = crate::persistence::ResourcesPersistence::controlled();
let tasks = vec![expired_task("expired", true), due_one_shot("plain")];
let (mut actor, mut notifications) = make_boundary_actor(tasks, 0);
actor.resources_persistence = Arc::new(persistence);
{
let expiry = actor.fire_next_task();
tokio::pin!(expiry);
let (_, persisted) = tokio::select! {
_ = expiry.as_mut() => panic!("expiry must wait for resource persistence"),
save = next_event(&mut saves) => save,
};
persisted
.send(Err(std::io::Error::other("disk unavailable")))
.unwrap();
tokio::time::timeout(Duration::from_secs(1), expiry.as_mut())
.await
.unwrap();
}
assert!(actor.blocked_expiries.contains("expired"));
assert_eq!(actor.clock.snapshot().revision(), 0);
assert!(notifications.try_recv().is_err());
actor.fire_next_task().await;
assert_eq!(
(
notification!(notifications.try_recv().unwrap(), ScheduledTaskFired).task_id,
notification!(notifications.try_recv().unwrap(), ScheduledTaskRemoved).task_id,
),
("plain".to_string(), "plain".to_string())
);
actor.fire_next_task().await;
assert!(saves.try_recv().is_err());
assert!(notifications.try_recv().is_err());
}
#[tokio::test]
async fn expiry_without_durable_target_blocks_only_expiry() {
let tasks = vec![expired_task("expired", true), due_one_shot("plain")];
let (mut actor, _) = make_boundary_actor(tasks, 0);
let (notification_handle, mut notifications) = ToolNotificationHandle::channel();
actor.notification_handle = notification_handle;
actor.fire_next_task().await;
assert!(actor.blocked_expiries.contains("expired"));
assert!(notifications.try_recv().is_err());
actor.fire_next_task().await;
assert_eq!(
(
notification!(notifications.try_recv().unwrap(), ScheduledTaskFired).task_id,
notification!(notifications.try_recv().unwrap(), ScheduledTaskRemoved).task_id,
),
("plain".to_string(), "plain".to_string())
);
actor.fire_next_task().await;
assert!(notifications.try_recv().is_err());
}
#[tokio::test]
async fn expiry_ack_failure_leaves_absent_and_continues_without_version_commit() {
let tasks = vec![expired_task("expired", true), due_one_shot("plain")];
let mut actor = make_boundary_actor(tasks, 0).0;
let dir = tempfile::tempdir().unwrap();
let state_path = dir.path().join("resources_state.json");
actor.resources_persistence = Arc::new(crate::persistence::ResourcesPersistence::new(
state_path.clone(),
));
let (notification_handle, mut notifications) =
ToolNotificationHandle::acknowledged_channel();
actor.notification_handle = notification_handle;
let removed_revision = {
let expiry = actor.fire_next_task();
tokio::pin!(expiry);
let delivery = tokio::select! {
_ = expiry.as_mut() => panic!("expiry must wait for tombstone acknowledgement"),
delivery = next_acknowledged(&mut notifications) => delivery,
};
let removed = notification!(delivery.notification, ScheduledTaskRemoved);
let removed_revision = removed.revision;
delivery
.acknowledgement
.unwrap()
.send(Err("append failed".into()))
.unwrap();
tokio::time::timeout(Duration::from_secs(1), expiry.as_mut())
.await
.unwrap();
removed_revision
};
assert_eq!(actor.clock.snapshot().revision(), 0);
assert!(
actor
.resources
.lock()
.await
.get::<State<SchedulerState>>()
.unwrap()
.tasks
.iter()
.all(|task| task.id != "expired")
);
let persisted: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(state_path).unwrap()).unwrap();
assert!(
persisted["state"]["grok_build.Scheduler"]["tasks"]
.as_array()
.unwrap()
.iter()
.all(|task| task["id"] != "expired")
);
actor.fire_next_task().await;
assert_eq!(
notification!(
next_acknowledged(&mut notifications).await.notification,
ScheduledTaskFired
)
.task_id,
"plain"
);
let plain_removed = next_acknowledged(&mut notifications).await;
assert!(plain_removed.acknowledgement.is_none());
assert_eq!(
notification!(plain_removed.notification, ScheduledTaskRemoved).revision,
removed_revision + 1
);
actor.fire_next_task().await;
assert!(notifications.try_recv().is_err());
}
#[tokio::test]
async fn expiry_persistence_cancellation_leaves_absent_and_stops_actor() {
let (persistence, mut saves) = crate::persistence::ResourcesPersistence::controlled();
let mut resources = Resources::new();
resources.register_state::<SchedulerState>();
resources.get_or_default::<State<SchedulerState>>().tasks =
vec![expired_task("expired", true)];
let shared = Arc::new(Mutex::new(resources));
let (notification_handle, mut notifications) =
ToolNotificationHandle::acknowledged_channel();
let (_cmd_tx, cmd_rx) = mpsc::unbounded_channel();
let cancel_token = CancellationToken::new();
let actor = SchedulerActor {
resources: shared.clone(),
resources_persistence: Arc::new(persistence),
notification_handle,
cmd_rx,
cancel_token: cancel_token.clone(),
clock: SchedulerClock::new(),
pending_removal: None,
blocked_expiries: HashSet::new(),
};
let actor_task = tokio::spawn(actor.run());
let (_, _withheld) = next_event(&mut saves).await;
let announced = next_acknowledged(&mut notifications).await;
assert!(announced.acknowledgement.is_none());
assert!(matches!(
announced.notification,
ToolNotification::ScheduledTaskCreated(_)
));
assert!(!actor_task.is_finished());
cancel_token.cancel();
tokio::time::timeout(Duration::from_secs(1), actor_task)
.await
.unwrap()
.unwrap();
assert!(
shared
.lock()
.await
.get::<State<SchedulerState>>()
.unwrap()
.tasks
.is_empty()
);
assert!(notifications.try_recv().is_err());
}
#[tokio::test]
async fn cancel_with_no_tasks_sends_no_removed() {
let mut resources = Resources::new();
@ -2451,6 +2803,7 @@ mod tests {
cancel_token: cancel_token.clone(),
clock: SchedulerClock::new(),
pending_removal: None,
blocked_expiries: HashSet::new(),
};
let handle = tokio::spawn(actor.run());

View file

@ -302,6 +302,7 @@ mod tests {
cancel_token: cancel_token.clone(),
clock: Default::default(),
pending_removal: None,
blocked_expiries: Default::default(),
};
tokio::spawn(actor.run());
(shared, cancel_token)

View file

@ -3,4 +3,5 @@ pub mod create;
pub mod delete;
pub mod interval;
pub mod list;
pub(crate) mod occurrence_journal;
pub mod types;

View file

@ -0,0 +1,566 @@
//! Persisted one-shot removal receipts and restart reconciliation.
//!
//! A receipt records task absence and exact fire/removal versions in one JSON resources
//! snapshot. Recovery is a pure plan: it reports removals requiring persistence and
//! timer suppression while all state mutation/publication remains in the actor layer.
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use super::types::{ScheduledTask, SchedulerState, SchedulerVersion};
pub(super) const MAX_PENDING_ONE_SHOTS: usize = 50;
const MAX_QUARANTINED_TASK_IDS: usize = 50;
const MAX_TASK_ID_BYTES: usize = 256;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(transparent)]
pub(crate) struct ScheduledOccurrenceId(uuid::Uuid);
impl ScheduledOccurrenceId {
fn new() -> Self {
Self(uuid::Uuid::now_v7())
}
}
impl<'de> Deserialize<'de> for ScheduledOccurrenceId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let id = uuid::Uuid::deserialize(deserializer)?;
if id.get_version() != Some(uuid::Version::SortRand)
|| id.get_variant() != uuid::Variant::RFC4122
{
return Err(serde::de::Error::custom(
"scheduled occurrence identity must be an RFC UUIDv7",
));
}
Ok(Self(id))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ScheduledOccurrenceVersions {
fire: SchedulerVersion,
removal: SchedulerVersion,
}
impl ScheduledOccurrenceVersions {
pub(super) fn try_new(
fire: SchedulerVersion,
removal: SchedulerVersion,
) -> Result<Self, OccurrenceJournalError> {
let generation = fire.generation_id();
if generation.get_version() != Some(uuid::Version::SortRand)
|| generation.get_variant() != uuid::Variant::RFC4122
|| fire.revision() == 0
|| removal.generation_id() != generation
|| fire
.revision()
.checked_add(1)
.is_none_or(|revision| removal.revision() != revision)
{
return Err(OccurrenceJournalError::InvalidVersions);
}
Ok(Self { fire, removal })
}
pub(super) fn fire(self) -> SchedulerVersion {
self.fire
}
pub(super) fn removal(self) -> SchedulerVersion {
self.removal
}
fn contains(self, version: SchedulerVersion) -> bool {
self.fire == version || self.removal == version
}
}
impl<'de> Deserialize<'de> for ScheduledOccurrenceVersions {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PersistedVersions {
fire: SchedulerVersion,
removal: SchedulerVersion,
}
let persisted = PersistedVersions::deserialize(deserializer)?;
Self::try_new(persisted.fire, persisted.removal).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OneShotOccurrence {
occurrence_id: ScheduledOccurrenceId,
task: ScheduledTask,
versions: ScheduledOccurrenceVersions,
}
impl<'de> Deserialize<'de> for OneShotOccurrence {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PersistedOccurrence {
occurrence_id: ScheduledOccurrenceId,
task: ScheduledTask,
versions: ScheduledOccurrenceVersions,
}
let persisted = PersistedOccurrence::deserialize(deserializer)?;
if persisted.task.recurring || !persisted.task.durable {
return Err(serde::de::Error::custom(
OccurrenceJournalError::NotDurableOneShot(persisted.task.id),
));
}
Ok(Self {
occurrence_id: persisted.occurrence_id,
task: persisted.task,
versions: persisted.versions,
})
}
}
#[derive(Debug, Clone, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OccurrenceJournal {
entries: Vec<OneShotOccurrence>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
quarantined_task_ids: Vec<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
block_all_one_shots: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
overflowed: bool,
}
impl OccurrenceJournal {
pub(super) fn is_empty(&self) -> bool {
self.entries.is_empty()
&& self.quarantined_task_ids.is_empty()
&& !self.block_all_one_shots
&& !self.overflowed
}
#[cfg_attr(
not(test),
expect(dead_code, reason = "wired by durable one-shot actor layer")
)]
pub(super) fn quarantine_diagnostics(&self) -> (&[String], bool, bool) {
(
&self.quarantined_task_ids,
self.block_all_one_shots,
self.overflowed,
)
}
}
/// JSON-only because Resources persistence stores this state as `serde_json::Value`.
impl<'de> Deserialize<'de> for OccurrenceJournal {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
Ok(Self::decode_json(value))
}
}
impl OccurrenceJournal {
fn decode_json(value: serde_json::Value) -> Self {
let (entries, task_ids, block_all, overflowed, is_malformed) = match value {
serde_json::Value::Array(entries) => (entries, Vec::new(), false, false, false),
serde_json::Value::Object(mut object) => {
let (entries, bad_entries) = parse_json_array(object.remove("entries"));
let (task_values, bad_task_ids) =
parse_json_array(object.remove("quarantinedTaskIds"));
let bad_task_element = task_values.iter().any(|value| !value.is_string());
let task_ids: Vec<String> = task_values
.into_iter()
.filter_map(|value| value.as_str().map(str::to_owned))
.collect();
let (block_all, bad_block) = parse_json_bool(object.remove("blockAllOneShots"));
let (overflowed, bad_overflow) = parse_json_bool(object.remove("overflowed"));
(
entries,
task_ids,
block_all,
overflowed,
bad_entries || bad_task_ids || bad_task_element || bad_block || bad_overflow,
)
}
_ => (Vec::new(), Vec::new(), true, false, true),
};
let mut journal = Self {
block_all_one_shots: block_all || overflowed || is_malformed,
overflowed,
..Self::default()
};
for task_id in task_ids {
journal.quarantine_task_id(task_id);
}
if entries.len() > MAX_PENDING_ONE_SHOTS {
journal.block_all_one_shots = true;
journal.overflowed = true;
}
for value in entries.into_iter().take(MAX_PENDING_ONE_SHOTS) {
match serde_json::from_value(value.clone()) {
Ok(occurrence) => journal.entries.push(occurrence),
Err(_) => match quarantined_task_id(&value) {
Some(task_id) => journal.quarantine_task_id(task_id),
None => journal.block_all_one_shots = true,
},
}
}
journal
}
fn quarantine_task_id(&mut self, task_id: String) {
if task_id.is_empty() || task_id.len() > MAX_TASK_ID_BYTES {
self.block_all_one_shots = true;
} else if !self.quarantined_task_ids.contains(&task_id) {
if self.quarantined_task_ids.len() == MAX_QUARANTINED_TASK_IDS {
self.block_all_one_shots = true;
} else {
self.quarantined_task_ids.push(task_id);
}
}
}
}
fn parse_json_array(value: Option<serde_json::Value>) -> (Vec<serde_json::Value>, bool) {
value.map_or((Vec::new(), false), |value| match value {
serde_json::Value::Array(values) => (values, false),
_ => (Vec::new(), true),
})
}
fn parse_json_bool(value: Option<serde_json::Value>) -> (bool, bool) {
value.map_or((false, false), |value| match value {
serde_json::Value::Bool(value) => (value, false),
_ => (true, true),
})
}
fn quarantined_task_id(value: &serde_json::Value) -> Option<String> {
value.get("task")?.get("id")?.as_str().map(str::to_owned)
}
#[derive(thiserror::Error, Debug, PartialEq, Eq)]
pub(crate) enum OccurrenceJournalError {
#[error("scheduled task {0} was not found")]
TaskNotFound(String),
#[error("scheduled task {0} is not a durable one-shot")]
NotDurableOneShot(String),
#[error("scheduled task {0} already has a pending occurrence")]
TaskAlreadyJournaled(String),
#[error("maximum of {MAX_PENDING_ONE_SHOTS} pending one-shot occurrences reached")]
JournalFull,
#[error("one-shot fire/removal versions must be nonzero consecutive RFC UUIDv7 transitions")]
InvalidVersions,
#[error("scheduler transition version is already journaled")]
DuplicateTransitionVersion,
#[error("one-shot journal requires manual recovery before new occurrences can be prepared")]
RecoveryRequired,
#[error("scheduled occurrence was not found")]
OccurrenceNotFound,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum OneShotJournalConflict {
OccurrenceId,
TaskId,
TransitionVersion,
}
#[must_use = "loaded one-shot receipts must suppress timers and reconcile resources"]
pub(crate) struct SchedulerLoadReconciliation {
requires_resources_persistence: bool,
task_ids_to_remove: Vec<String>,
blocked_task_ids: HashSet<String>,
block_all_one_shots: bool,
recovery_required: bool,
conflicts: Vec<OneShotJournalConflict>,
overflow_error: Option<OccurrenceJournalError>,
}
impl SchedulerLoadReconciliation {
#[cfg_attr(
not(test),
expect(dead_code, reason = "wired by durable one-shot actor layer")
)]
pub(super) fn requires_resources_persistence(&self) -> bool {
self.requires_resources_persistence
}
#[cfg_attr(
not(test),
expect(dead_code, reason = "wired by durable one-shot actor layer")
)]
pub(super) fn task_ids_to_remove(&self) -> &[String] {
&self.task_ids_to_remove
}
#[cfg_attr(
not(test),
expect(dead_code, reason = "wired by durable one-shot actor layer")
)]
pub(super) fn blocked_task_ids(&self) -> &HashSet<String> {
&self.blocked_task_ids
}
#[cfg_attr(
not(test),
expect(dead_code, reason = "wired by durable one-shot actor layer")
)]
pub(super) fn block_all_one_shots(&self) -> bool {
self.block_all_one_shots
}
#[cfg_attr(
not(test),
expect(dead_code, reason = "wired by durable one-shot actor layer")
)]
pub(super) fn recovery_required(&self) -> bool {
self.recovery_required
}
#[cfg_attr(
not(test),
expect(dead_code, reason = "wired by durable one-shot actor layer")
)]
pub(super) fn conflicts(&self) -> &[OneShotJournalConflict] {
&self.conflicts
}
#[cfg_attr(
not(test),
expect(dead_code, reason = "wired by durable one-shot actor layer")
)]
pub(super) fn overflow_error(&self) -> Option<&OccurrenceJournalError> {
self.overflow_error.as_ref()
}
}
impl SchedulerState {
#[cfg_attr(
not(test),
expect(dead_code, reason = "wired by durable one-shot actor layer")
)]
pub(super) fn prepare_one_shot_occurrence(
&mut self,
task_id: &str,
versions: ScheduledOccurrenceVersions,
) -> Result<OneShotOccurrence, OccurrenceJournalError> {
self.prepare_one_shot_occurrence_with_id(ScheduledOccurrenceId::new(), task_id, versions)
}
fn prepare_one_shot_occurrence_with_id(
&mut self,
occurrence_id: ScheduledOccurrenceId,
task_id: &str,
versions: ScheduledOccurrenceVersions,
) -> Result<OneShotOccurrence, OccurrenceJournalError> {
if !self.occurrence_journal.quarantined_task_ids.is_empty()
|| self.occurrence_journal.block_all_one_shots
|| self.occurrence_journal.overflowed
|| has_conflict(&self.occurrence_journal.entries)
{
return Err(OccurrenceJournalError::RecoveryRequired);
}
if self.occurrence_journal.entries.len() >= MAX_PENDING_ONE_SHOTS {
return Err(OccurrenceJournalError::JournalFull);
}
if self
.occurrence_journal
.entries
.iter()
.any(|occurrence| occurrence.task.id == task_id)
{
return Err(OccurrenceJournalError::TaskAlreadyJournaled(
task_id.to_owned(),
));
}
if self.occurrence_journal.entries.iter().any(|occurrence| {
occurrence.versions.contains(versions.fire())
|| occurrence.versions.contains(versions.removal())
}) {
return Err(OccurrenceJournalError::DuplicateTransitionVersion);
}
let index = self
.tasks
.iter()
.position(|task| task.id == task_id)
.ok_or_else(|| OccurrenceJournalError::TaskNotFound(task_id.to_owned()))?;
if self.tasks[index].recurring || !self.tasks[index].durable {
return Err(OccurrenceJournalError::NotDurableOneShot(
task_id.to_owned(),
));
}
let occurrence = OneShotOccurrence {
occurrence_id,
task: self.tasks.remove(index),
versions,
};
self.occurrence_journal.entries.push(occurrence.clone());
Ok(occurrence)
}
#[must_use = "the exact removal receipt must be durably cleared"]
#[cfg_attr(
not(test),
expect(dead_code, reason = "wired by durable one-shot actor layer")
)]
pub(super) fn finish_one_shot_removal(
&mut self,
occurrence_id: &ScheduledOccurrenceId,
) -> Result<OneShotOccurrence, OccurrenceJournalError> {
let index = self
.occurrence_journal
.entries
.iter()
.position(|occurrence| occurrence.occurrence_id == *occurrence_id)
.ok_or(OccurrenceJournalError::OccurrenceNotFound)?;
Ok(self.occurrence_journal.entries.remove(index))
}
#[cfg_attr(
not(test),
expect(dead_code, reason = "wired by durable one-shot actor layer")
)]
pub(super) fn reconcile_one_shot_occurrences(&self) -> SchedulerLoadReconciliation {
let occurrence_counts = count_by(self.occurrence_journal.entries.iter(), |entry| {
entry.occurrence_id.clone()
});
let task_counts = count_by(self.occurrence_journal.entries.iter(), |entry| {
entry.task.id.clone()
});
let mut version_counts = HashMap::new();
for occurrence in &self.occurrence_journal.entries {
for version in [occurrence.versions.fire(), occurrence.versions.removal()] {
*version_counts.entry(version).or_insert(0usize) += 1;
}
}
let conflict_for = |occurrence: &OneShotOccurrence| {
if occurrence_counts[&occurrence.occurrence_id] > 1 {
Some(OneShotJournalConflict::OccurrenceId)
} else if task_counts[&occurrence.task.id] > 1 {
Some(OneShotJournalConflict::TaskId)
} else if version_counts[&occurrence.versions.fire()] > 1
|| version_counts[&occurrence.versions.removal()] > 1
{
Some(OneShotJournalConflict::TransitionVersion)
} else {
None
}
};
let mut blocked_task_ids: HashSet<String> = self
.occurrence_journal
.quarantined_task_ids
.iter()
.cloned()
.collect();
let block_all_one_shots = self.occurrence_journal.block_all_one_shots;
let overflowed = self.occurrence_journal.overflowed;
let conflicts: Vec<_> = self
.occurrence_journal
.entries
.iter()
.filter_map(conflict_for)
.collect();
let recovery_required = block_all_one_shots
|| overflowed
|| !self.occurrence_journal.quarantined_task_ids.is_empty()
|| !conflicts.is_empty();
blocked_task_ids.extend(
self.occurrence_journal
.entries
.iter()
.map(|occurrence| occurrence.task.id.clone()),
);
if block_all_one_shots {
blocked_task_ids.extend(
self.tasks
.iter()
.filter(|task| !task.recurring)
.map(|task| task.id.clone()),
);
}
let task_ids_to_remove: Vec<String> = if recovery_required {
Vec::new()
} else {
let journaled: HashSet<&str> = self
.occurrence_journal
.entries
.iter()
.map(|occurrence| occurrence.task.id.as_str())
.collect();
self.tasks
.iter()
.filter(|task| journaled.contains(task.id.as_str()))
.map(|task| task.id.clone())
.collect()
};
SchedulerLoadReconciliation {
requires_resources_persistence: !task_ids_to_remove.is_empty(),
task_ids_to_remove,
blocked_task_ids,
block_all_one_shots,
recovery_required,
conflicts,
overflow_error: overflowed.then_some(OccurrenceJournalError::JournalFull),
}
}
}
fn has_conflict(entries: &[OneShotOccurrence]) -> bool {
let occurrence_ids: HashSet<_> = entries.iter().map(|entry| &entry.occurrence_id).collect();
let task_ids: HashSet<_> = entries.iter().map(|entry| entry.task.id.as_str()).collect();
let versions: HashSet<_> = entries
.iter()
.flat_map(|entry| [entry.versions.fire(), entry.versions.removal()])
.collect();
occurrence_ids.len() != entries.len()
|| task_ids.len() != entries.len()
|| versions.len() != entries.len() * 2
}
fn count_by<'a, T, K>(
values: impl Iterator<Item = &'a T>,
key: impl Fn(&T) -> K,
) -> HashMap<K, usize>
where
T: 'a,
K: Eq + std::hash::Hash,
{
let mut counts = HashMap::new();
for value in values {
*counts.entry(key(value)).or_insert(0) += 1;
}
counts
}
#[cfg(test)]
#[path = "occurrence_journal_tests.rs"]
mod tests;

View file

@ -0,0 +1,391 @@
use super::*;
use crate::persistence::ResourcesPersistence;
use crate::types::resources::{Resources, State};
use chrono::{TimeZone, Utc};
const GENERATION: &str = "01890f42-7d5c-7c00-8000-000000000001";
fn uuid(suffix: u64) -> uuid::Uuid {
uuid::Uuid::parse_str(&format!("01890f42-7d5c-7c00-8000-{suffix:012x}")).unwrap()
}
fn task(id: &str, recurring: bool, durable: bool) -> ScheduledTask {
ScheduledTask {
id: id.into(),
interval_secs: 300,
prompt: format!("run {id}"),
recurring,
durable,
foreground: true,
created_at: Utc.timestamp_opt(1_700_000_000, 0).unwrap(),
last_fired_at: None,
expires_at: None,
last_subagent_id: None,
iterations_since_fresh: 0,
chain_reset_pending: false,
}
}
fn version(generation: &str, revision: u64) -> SchedulerVersion {
SchedulerVersion::from_parts(uuid::Uuid::parse_str(generation).unwrap(), revision)
}
fn versions(revision: u64) -> ScheduledOccurrenceVersions {
ScheduledOccurrenceVersions::try_new(
version(GENERATION, revision),
version(GENERATION, revision + 1),
)
.unwrap()
}
fn occurrence_json(
id: &str,
task: serde_json::Value,
versions: serde_json::Value,
) -> serde_json::Value {
serde_json::json!({ "occurrenceId": id, "task": task, "versions": versions })
}
fn valid_occurrence_json(id_suffix: u64, task_id: &str, revision: u64) -> serde_json::Value {
occurrence_json(
&uuid(id_suffix).to_string(),
serde_json::to_value(task(task_id, false, true)).unwrap(),
serde_json::json!({
"fire": { "generation": GENERATION, "revision": revision },
"removal": { "generation": GENERATION, "revision": revision + 1 },
}),
)
}
fn state(tasks: Vec<ScheduledTask>, journal: serde_json::Value) -> SchedulerState {
serde_json::from_value(serde_json::json!({
"tasks": tasks,
"occurrenceJournal": journal
}))
.unwrap()
}
fn prepare(state: &mut SchedulerState, task_id: &str, revision: u64) -> OneShotOccurrence {
state
.prepare_one_shot_occurrence_with_id(
ScheduledOccurrenceId(uuid(100 + revision)),
task_id,
versions(revision),
)
.unwrap()
}
#[test]
fn prepare_finish_and_mutation_failures_preserve_state() {
let mut state = SchedulerState {
tasks: vec![task("one-shot", false, true), task("second", false, true)],
..Default::default()
};
let occurrence = prepare(&mut state, "one-shot", 7);
assert_eq!(occurrence.task.id, "one-shot");
state
.finish_one_shot_removal(&occurrence.occurrence_id)
.unwrap();
prepare(&mut state, "second", 1);
state.tasks.push(task("duplicate", false, true));
assert_eq!(
state
.prepare_one_shot_occurrence("duplicate", versions(1))
.unwrap_err(),
OccurrenceJournalError::DuplicateTransitionVersion
);
for invalid in [
task("recurring", true, true),
task("ephemeral", false, false),
] {
let mut state = SchedulerState {
tasks: vec![invalid.clone()],
..Default::default()
};
assert!(matches!(
state.prepare_one_shot_occurrence(&invalid.id, versions(3)),
Err(OccurrenceJournalError::NotDurableOneShot(_))
));
}
}
#[test]
fn validation_rejects_impossible_versions_and_non_rfc_identity() {
for (fire_generation, removal_generation, fire, removal) in [
(GENERATION, GENERATION, 0, 1),
(GENERATION, GENERATION, 1, 3),
(GENERATION, "01890f42-7d5c-7c00-8000-000000000002", 1, 2),
("01890f42-7d5c-7c00-c000-000000000001", GENERATION, 1, 2),
] {
assert_eq!(
ScheduledOccurrenceVersions::try_new(
version(fire_generation, fire),
version(removal_generation, removal),
),
Err(OccurrenceJournalError::InvalidVersions)
);
}
let invalid = occurrence_json(
"01890f42-7d5c-7c00-c000-000000000001",
serde_json::to_value(task("bad-id", false, true)).unwrap(),
serde_json::json!({
"fire": { "generation": GENERATION, "revision": 1 },
"removal": { "generation": GENERATION, "revision": 2 },
}),
);
let state = state(Vec::new(), serde_json::Value::Array(vec![invalid]));
let plan = state.reconcile_one_shot_occurrences();
assert!(plan.recovery_required() && plan.blocked_task_ids().contains("bad-id"));
}
#[test]
fn exactly_fifty_round_trips_and_mutation_reports_journal_full() {
let entries: Vec<_> = (0..MAX_PENDING_ONE_SHOTS)
.map(|index| {
valid_occurrence_json(
100 + index as u64,
&format!("task-{index}"),
index as u64 * 2 + 1,
)
})
.collect();
let mut state = state(Vec::new(), serde_json::Value::Array(entries));
assert_eq!(
state.occurrence_journal.entries.len(),
MAX_PENDING_ONE_SHOTS
);
let encoded = serde_json::to_value(&state).unwrap();
let reloaded: SchedulerState = serde_json::from_value(encoded).unwrap();
assert_eq!(
reloaded.occurrence_journal.entries.len(),
MAX_PENDING_ONE_SHOTS
);
state.tasks.push(task("new", false, true));
assert_eq!(
state
.prepare_one_shot_occurrence("new", versions(3))
.unwrap_err(),
OccurrenceJournalError::JournalFull
);
}
#[test]
fn overflow_tail_suppresses_globally_and_never_serializes_a_fifty_first_entry() {
let mut entries: Vec<_> = (0..MAX_PENDING_ONE_SHOTS)
.map(|index| valid_occurrence_json(200 + index as u64, &format!("task-{index}"), 1))
.collect();
entries.push(valid_occurrence_json(999, "tail-task", 3));
let state = state(
vec![task("tail-task", false, true), task("other", false, true)],
serde_json::Value::Array(entries),
);
let plan = state.reconcile_one_shot_occurrences();
assert!(plan.block_all_one_shots() && plan.recovery_required());
assert!(!plan.requires_resources_persistence());
assert!(plan.blocked_task_ids().contains("tail-task"));
assert!(plan.overflow_error().is_some());
let encoded = serde_json::to_value(&state).unwrap();
assert_eq!(
encoded["occurrenceJournal"]["entries"]
.as_array()
.unwrap()
.len(),
MAX_PENDING_ONE_SHOTS
);
let mut reloaded: SchedulerState = serde_json::from_value(encoded).unwrap();
let reloaded_plan = reloaded.reconcile_one_shot_occurrences();
assert!(reloaded_plan.block_all_one_shots() && reloaded_plan.recovery_required());
reloaded.tasks.push(task("new", false, true));
let before = reloaded.tasks.len();
assert_eq!(
reloaded
.prepare_one_shot_occurrence("new", versions(5))
.unwrap_err(),
OccurrenceJournalError::RecoveryRequired
);
assert_eq!(reloaded.tasks.len(), before);
}
#[test]
fn malformed_missing_task_identity_blocks_all_one_shots_across_reload() {
let malformed = occurrence_json(
&uuid(20).to_string(),
serde_json::json!({ "prompt": "missing id" }),
serde_json::json!({
"fire": { "generation": GENERATION, "revision": 1 },
"removal": { "generation": GENERATION, "revision": 2 },
}),
);
let state = state(
vec![task("due", false, true), task("recurring", true, true)],
serde_json::Value::Array(vec![malformed]),
);
let plan = state.reconcile_one_shot_occurrences();
assert!(plan.block_all_one_shots() && plan.recovery_required());
assert!(plan.blocked_task_ids().contains("due"));
let encoded = serde_json::to_value(&state).unwrap();
let reloaded: SchedulerState = serde_json::from_value(encoded).unwrap();
let reloaded_plan = reloaded.reconcile_one_shot_occurrences();
assert!(reloaded_plan.block_all_one_shots() && reloaded_plan.recovery_required());
}
#[test]
fn inconsistent_current_overflow_metadata_normalizes_and_round_trips() {
let current = serde_json::json!({
"entries": [],
"overflowed": true,
"blockAllOneShots": false,
});
let state = state(vec![task("due", false, true)], current);
let plan = state.reconcile_one_shot_occurrences();
assert!(plan.block_all_one_shots() && plan.recovery_required());
let encoded = serde_json::to_value(&state).unwrap();
assert!(encoded["occurrenceJournal"]["blockAllOneShots"] == true);
let reloaded: SchedulerState = serde_json::from_value(encoded).unwrap();
assert!(
reloaded
.reconcile_one_shot_occurrences()
.recovery_required()
);
}
#[tokio::test]
async fn production_loader_preserves_tasks_and_quarantine_metadata() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("resources_state.json");
let invalid = occurrence_json(
&uuid(30).to_string(),
serde_json::to_value(task("bad", true, true)).unwrap(),
serde_json::json!({
"fire": { "generation": GENERATION, "revision": 1 },
"removal": { "generation": GENERATION, "revision": 2 },
}),
);
std::fs::write(
&path,
serde_json::to_vec(&serde_json::json!({
"state": { "grok_build.Scheduler": {
"tasks": [task("recurring", true, true)],
"occurrenceJournal": [invalid]
} }
}))
.unwrap(),
)
.unwrap();
let mut resources = Resources::new();
resources.register_state::<SchedulerState>();
assert!(ResourcesPersistence::new(path.clone()).load(&mut resources));
let state = resources.get::<State<SchedulerState>>().unwrap();
assert_eq!(state.tasks[0].id, "recurring");
let (task_ids, is_global_block, is_overflowed) =
state.occurrence_journal.quarantine_diagnostics();
assert_eq!(task_ids, ["bad"]);
assert!(!is_global_block && !is_overflowed);
for journal in [
serde_json::json!({ "entries": "bad", "blockAllOneShots": [] }),
serde_json::json!({ "quarantinedTaskIds": ["kept-id", 7] }),
serde_json::json!("wrong-shape"),
] {
std::fs::write(
&path,
serde_json::to_vec(&serde_json::json!({
"state": { "grok_build.Scheduler": {
"tasks": [task("kept", true, true)],
"occurrenceJournal": journal
} }
}))
.unwrap(),
)
.unwrap();
let mut resources = Resources::new();
resources.register_state::<SchedulerState>();
assert!(ResourcesPersistence::new(path.clone()).load(&mut resources));
let state = resources.get::<State<SchedulerState>>().unwrap();
assert_eq!(state.tasks[0].id, "kept");
assert!(state.occurrence_journal.block_all_one_shots);
}
}
#[test]
fn reconciliation_exposes_only_persistence_and_suppression_foundation() {
let state = state(
vec![task("resurrected", false, true)],
serde_json::Value::Array(vec![valid_occurrence_json(10, "resurrected", 1)]),
);
let plan = state.reconcile_one_shot_occurrences();
assert!(plan.requires_resources_persistence());
assert_eq!(plan.task_ids_to_remove(), ["resurrected"]);
assert_eq!(state.tasks[0].id, "resurrected");
}
#[test]
fn conflict_receipts_produce_diagnostics_and_suppress_every_task() {
for (entries, expected) in [
(
vec![
valid_occurrence_json(10, "first", 1),
valid_occurrence_json(10, "second", 3),
],
OneShotJournalConflict::OccurrenceId,
),
(
vec![
valid_occurrence_json(10, "same", 1),
valid_occurrence_json(11, "same", 3),
],
OneShotJournalConflict::TaskId,
),
(
vec![
valid_occurrence_json(10, "first", 1),
valid_occurrence_json(11, "second", 1),
],
OneShotJournalConflict::TransitionVersion,
),
] {
let ids: Vec<_> = entries
.iter()
.map(|entry| entry["task"]["id"].as_str().unwrap().to_owned())
.collect();
let mut state = state(
ids.iter().map(|id| task(id, false, true)).collect(),
serde_json::Value::Array(entries),
);
let plan = state.reconcile_one_shot_occurrences();
assert!(plan.recovery_required());
assert!(plan.task_ids_to_remove().is_empty());
assert_eq!(plan.conflicts(), &[expected, expected]);
assert!(ids.iter().all(|id| plan.blocked_task_ids().contains(id)));
assert_eq!(state.tasks.len(), ids.len());
let unrelated = "unrelated";
state.tasks.push(task(unrelated, false, true));
let before = state.tasks.len();
assert_eq!(
state
.prepare_one_shot_occurrence(unrelated, versions(9))
.unwrap_err(),
OccurrenceJournalError::RecoveryRequired
);
assert_eq!(state.tasks.len(), before);
}
}
#[test]
fn empty_journal_omits_legacy_field() {
let serialized = serde_json::to_value(SchedulerState {
tasks: vec![task("legacy", true, true)],
..Default::default()
})
.unwrap();
assert!(serialized.get("occurrenceJournal").is_none());
}

View file

@ -2,7 +2,8 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tokio::sync::{mpsc, oneshot};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SchedulerVersion {
generation: uuid::Uuid,
revision: u64,
@ -16,6 +17,18 @@ impl SchedulerVersion {
pub(super) fn revision(self) -> u64 {
self.revision
}
pub(super) fn generation_id(self) -> uuid::Uuid {
self.generation
}
#[cfg(test)]
pub(super) fn from_parts(generation: uuid::Uuid, revision: u64) -> Self {
Self {
generation,
revision,
}
}
}
#[derive(Debug)]
@ -279,7 +292,14 @@ impl ScheduledTask {
/// Persisted state for the scheduler, stored via Resources + ResourcesPersistence.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SchedulerState {
#[serde(default)]
pub tasks: Vec<ScheduledTask>,
#[serde(
default,
rename = "occurrenceJournal",
skip_serializing_if = "super::occurrence_journal::OccurrenceJournal::is_empty"
)]
pub(crate) occurrence_journal: super::occurrence_journal::OccurrenceJournal,
}
crate::register_resource!("grok_build", "Scheduler", SchedulerState);

View file

@ -767,6 +767,8 @@ impl crate::types::tool_metadata::ToolMetadata for SearchReplaceTool {
}
fn requires_expr(&self) -> Expr<ToolRequirement> {
Expr::And(vec![
// Unless `skip_read_before_edit` is set, require a Read tool in the toolset
// (read-before-edit is encouraged via description and RL grading, not runtime-enforced).
Expr::Value(ToolRequirement::if_params(
Expr::Not(Box::new(Expr::Value(ToolParamsRequirement::new(
"skip_read_before_edit",
@ -774,6 +776,12 @@ impl crate::types::tool_metadata::ToolMetadata for SearchReplaceTool {
)))),
ToolRequirement::tool_kind(ToolKind::Read),
)),
// Description template references these input params via
// ${{ params.edit.old_string }}, ${{ params.edit.new_string }},
// ${{ params.edit.replace_all }}. They must remain visible.
// TODO: We can generate the schemas and requirement by enforcing
// it during the registry phase, since these are parts of the params which are
// tied to the tool
Expr::Value(ToolRequirement::input_param(ToolKind::Edit, "old_string")),
Expr::Value(ToolRequirement::input_param(ToolKind::Edit, "new_string")),
Expr::Value(ToolRequirement::input_param(ToolKind::Edit, "replace_all")),
@ -903,7 +911,7 @@ mod tests {
/// Harness configs still send this field; it must keep validating under `deny_unknown_fields`.
#[test]
fn harness_skip_read_before_edit_param_still_validates() {
let json = serde_json::json!({ "skip_read_before_edit" : true });
let json = serde_json::json!({ "skip_read_before_edit": true });
crate::types::params_validation::validate_params_json::<SearchReplaceParams>(&json).expect(
"harness skip_read_before_edit config must validate against SearchReplaceParams",
);

View file

@ -527,6 +527,7 @@ pub enum SubagentCancelOutcome {
#[derive(Debug, Clone)]
pub struct SubagentCompletionSummary {
pub subagent_id: String,
pub owner_session_id: String,
pub subagent_type: String,
pub description: String,
pub success: bool,
@ -559,6 +560,7 @@ pub struct SubagentMultiWaitRequest {
#[derive(Educe)]
#[educe(Debug)]
pub struct SubagentCompletionsRequest {
pub session_id: String,
pub suppress_ids: Vec<String>,
#[educe(Debug(ignore))]
pub respond_to: oneshot::Sender<Vec<SubagentCompletionSummary>>,
@ -1280,16 +1282,19 @@ mod tests {
let (respond_to, mut response_rx) = oneshot::channel();
tx.send(super::SubagentCompletionsRequest {
session_id: "session-1".into(),
suppress_ids: vec!["id-1".into(), "id-2".into()],
respond_to,
})
.unwrap();
let req = rx.try_recv().unwrap();
assert_eq!(req.session_id, "session-1");
assert_eq!(req.suppress_ids, vec!["id-1", "id-2"]);
let summaries = vec![super::SubagentCompletionSummary {
subagent_id: "sub-1".into(),
owner_session_id: "session-1".into(),
subagent_type: "general-purpose".into(),
description: "test task".into(),
success: true,
@ -1368,6 +1373,7 @@ mod tests {
.0
.send(super::SubagentEvent::Completions(
super::SubagentCompletionsRequest {
session_id: String::new(),
suppress_ids: vec![],
respond_to,
},
@ -1399,6 +1405,7 @@ mod tests {
.0
.send(super::SubagentEvent::Completions(
super::SubagentCompletionsRequest {
session_id: String::new(),
suppress_ids: vec![],
respond_to,
},

View file

@ -79,15 +79,13 @@ pub struct EditInput {
)]
pub new_string: String,
/// When true, replace every occurrence of `old_string` (default false).
/// When true, replace every occurrence of `old_string`.
#[serde(
default,
deserialize_with = "crate::types::schema::deserialize_lenient_option_bool"
deserialize_with = "crate::types::schema::deserialize_lenient_bool"
)]
#[schemars(
description = "Replace all occurrences of ${{ params.edit.oldString }} (default false)"
)]
pub replace_all: Option<bool>,
#[schemars(description = "Replace all occurrences of ${{ params.edit.oldString }}")]
pub replace_all: bool,
}
// ───────────────────────────────────────────────────────────────────────────
@ -197,7 +195,7 @@ impl xai_tool_runtime::Tool for EditTool {
};
let tool_call_id = ctx.call_id.as_str().to_owned();
let replace_all = input.replace_all.unwrap_or(false);
let replace_all = input.replace_all;
// Resolve the model-provided path.
let path = resolve_model_path(&cwd, display_cwd.as_deref(), &input.file_path);
@ -521,10 +519,30 @@ mod tests {
file_path: file_path.to_string(),
old_string: old_string.to_string(),
new_string: new_string.to_string(),
replace_all: None,
replace_all: false,
}
}
#[test]
fn replace_all_defaults_false_and_schema_is_boolean() {
let missing: EditInput =
serde_json::from_str(r#"{"filePath":"/f","oldString":"a","newString":"b"}"#).unwrap();
assert!(!missing.replace_all);
let nullv: EditInput = serde_json::from_str(
r#"{"filePath":"/f","oldString":"a","newString":"b","replaceAll":null}"#,
)
.unwrap();
assert!(!nullv.replace_all);
let schema = serde_json::to_value(schemars::schema_for!(EditInput)).unwrap();
// rename_all = camelCase → replaceAll
let p = &schema["properties"]["replaceAll"];
assert_eq!(p["type"], "boolean", "schema: {schema}");
assert_eq!(p["default"], false, "schema: {schema}");
assert!(p.get("anyOf").is_none(), "schema: {schema}");
}
// ── Tool metadata ───────────────────────────────────────────────
#[test]
@ -560,7 +578,7 @@ mod tests {
assert_eq!(input.file_path, "src/main.rs");
assert_eq!(input.old_string, "hello");
assert_eq!(input.new_string, "goodbye");
assert_eq!(input.replace_all, Some(true));
assert!(input.replace_all);
}
#[test]
@ -572,7 +590,7 @@ mod tests {
});
let input: EditInput = serde_json::from_value(json).unwrap();
assert_eq!(input.file_path, "test.txt");
assert_eq!(input.replace_all, None);
assert!(!input.replace_all);
}
// ── Validation ──────────────────────────────────────────────────
@ -816,7 +834,7 @@ mod tests {
file_path: "test.txt".to_string(),
old_string: "aaa".to_string(),
new_string: "ccc".to_string(),
replace_all: Some(true),
replace_all: true,
};
let result = xai_tool_runtime::Tool::run(&tool, test_ctx(resources.into_shared()), input)
.await
@ -990,7 +1008,7 @@ mod tests {
file_path: "test.txt".to_string(),
old_string: "foo".to_string(),
new_string: "qux".to_string(),
replace_all: Some(true),
replace_all: true,
};
let result = xai_tool_runtime::Tool::run(&tool, test_ctx(resources.into_shared()), input)
.await

View file

@ -155,7 +155,10 @@ impl WebSearchClient {
return Err(xai_tool_runtime::ToolError::unauthorized(format!(
"Responses API returned 401 Unauthorized: {body}"
))
.with_details(serde_json::json!({ "tool_id" : "web_search", "status" : 401, })));
.with_details(serde_json::json!({
"tool_id": "web_search",
"status": 401,
})));
}
if !status.is_success() {
let body = response
@ -243,7 +246,10 @@ impl WebSearchClient {
return Err(xai_tool_runtime::ToolError::unauthorized(format!(
"Responses API returned 401 Unauthorized: {body}"
))
.with_details(serde_json::json!({ "tool_id" : "web_search", "status" : 401, })));
.with_details(serde_json::json!({
"tool_id": "web_search",
"status": 401,
})));
}
if !status.is_success() {
let body = response
@ -411,26 +417,56 @@ mod tests {
}
#[test]
fn test_extract_citations_empty_response() {
let response = response_from_json(serde_json::json!(
{ "id" : "resp_test", "object" : "response", "created_at" : 1234567890,
"status" : "completed", "output" : [], "model" : "test-model" }
));
let response = response_from_json(serde_json::json!({
"id": "resp_test",
"object": "response",
"created_at": 1234567890,
"status": "completed",
"output": [],
"model": "test-model"
}));
let citations = extract_citations(&response);
assert!(citations.is_empty());
}
#[test]
fn test_extract_citations_with_url_citations() {
let response = response_from_json(serde_json::json!(
{ "id" : "resp_test", "object" : "response", "created_at" : 1234567890,
"status" : "completed", "model" : "test-model", "output" : [{ "type" :
"message", "id" : "msg_1", "status" : "completed", "role" : "assistant",
"content" : [{ "type" : "output_text", "text" :
"Here is some info about Rust.", "annotations" : [{ "type" :
"url_citation", "url" : "https://www.rust-lang.org/", "title" :
"Rust Programming Language", "start_index" : 0, "end_index" : 10 }, {
"type" : "url_citation", "url" : "https://docs.rs/", "title" : "Docs.rs",
"start_index" : 11, "end_index" : 20 }] }] }] }
));
let response = response_from_json(serde_json::json!({
"id": "resp_test",
"object": "response",
"created_at": 1234567890,
"status": "completed",
"model": "test-model",
"output": [
{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "Here is some info about Rust.",
"annotations": [
{
"type": "url_citation",
"url": "https://www.rust-lang.org/",
"title": "Rust Programming Language",
"start_index": 0,
"end_index": 10
},
{
"type": "url_citation",
"url": "https://docs.rs/",
"title": "Docs.rs",
"start_index": 11,
"end_index": 20
}
]
}
]
}
]
}));
let citations = extract_citations(&response);
assert_eq!(citations.len(), 2);
assert_eq!(citations[0], "https://www.rust-lang.org/");
@ -438,19 +474,50 @@ mod tests {
}
#[test]
fn test_extract_citations_deduplicates() {
let response = response_from_json(serde_json::json!(
{ "id" : "resp_test", "object" : "response", "created_at" : 1234567890,
"status" : "completed", "model" : "test-model", "output" : [{ "type" :
"message", "id" : "msg_1", "status" : "completed", "role" : "assistant",
"content" : [{ "type" : "output_text", "text" :
"Info with duplicate citations.", "annotations" : [{ "type" :
"url_citation", "url" : "https://example.com/page1", "title" : "Page 1",
"start_index" : 0, "end_index" : 5 }, { "type" : "url_citation", "url" :
"https://example.com/page2", "title" : "Page 2", "start_index" : 6,
"end_index" : 10 }, { "type" : "url_citation", "url" :
"https://example.com/page1", "title" : "Page 1 Again", "start_index" :
11, "end_index" : 15 }] }] }] }
));
let response = response_from_json(serde_json::json!({
"id": "resp_test",
"object": "response",
"created_at": 1234567890,
"status": "completed",
"model": "test-model",
"output": [
{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "Info with duplicate citations.",
"annotations": [
{
"type": "url_citation",
"url": "https://example.com/page1",
"title": "Page 1",
"start_index": 0,
"end_index": 5
},
{
"type": "url_citation",
"url": "https://example.com/page2",
"title": "Page 2",
"start_index": 6,
"end_index": 10
},
{
"type": "url_citation",
"url": "https://example.com/page1",
"title": "Page 1 Again",
"start_index": 11,
"end_index": 15
}
]
}
]
}
]
}));
let citations = extract_citations(&response);
assert_eq!(citations.len(), 2);
assert_eq!(citations[0], "https://example.com/page1");
@ -458,19 +525,57 @@ mod tests {
}
#[test]
fn test_extract_citations_multiple_messages() {
let response = response_from_json(serde_json::json!(
{ "id" : "resp_test", "object" : "response", "created_at" : 1234567890,
"status" : "completed", "model" : "test-model", "output" : [{ "type" :
"message", "id" : "msg_1", "status" : "completed", "role" : "assistant",
"content" : [{ "type" : "output_text", "text" : "First message",
"annotations" : [{ "type" : "url_citation", "url" : "https://first.com/",
"title" : "First", "start_index" : 0, "end_index" : 5 }] }] }, { "type" :
"message", "id" : "msg_2", "status" : "completed", "role" : "assistant",
"content" : [{ "type" : "output_text", "text" : "Second message",
"annotations" : [{ "type" : "url_citation", "url" :
"https://second.com/", "title" : "Second", "start_index" : 0, "end_index"
: 6 }] }] }] }
));
let response = response_from_json(serde_json::json!({
"id": "resp_test",
"object": "response",
"created_at": 1234567890,
"status": "completed",
"model": "test-model",
"output": [
{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "First message",
"annotations": [
{
"type": "url_citation",
"url": "https://first.com/",
"title": "First",
"start_index": 0,
"end_index": 5
}
]
}
]
},
{
"type": "message",
"id": "msg_2",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "Second message",
"annotations": [
{
"type": "url_citation",
"url": "https://second.com/",
"title": "Second",
"start_index": 0,
"end_index": 6
}
]
}
]
}
]
}));
let citations = extract_citations(&response);
assert_eq!(citations.len(), 2);
assert_eq!(citations[0], "https://first.com/");
@ -478,14 +583,36 @@ mod tests {
}
#[test]
fn test_extract_citations_ignores_non_url_annotations() {
let response = response_from_json(serde_json::json!(
{ "id" : "resp_test", "object" : "response", "created_at" : 1234567890,
"status" : "completed", "model" : "test-model", "output" : [{ "type" :
"message", "id" : "msg_1", "status" : "completed", "role" : "assistant",
"content" : [{ "type" : "output_text", "text" : "Some text",
"annotations" : [{ "type" : "url_citation", "url" : "https://valid.com/",
"title" : "Valid", "start_index" : 0, "end_index" : 4 }] }] }] }
));
let response = response_from_json(serde_json::json!({
"id": "resp_test",
"object": "response",
"created_at": 1234567890,
"status": "completed",
"model": "test-model",
"output": [
{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "Some text",
"annotations": [
{
"type": "url_citation",
"url": "https://valid.com/",
"title": "Valid",
"start_index": 0,
"end_index": 4
}
]
}
]
}
]
}));
let citations = extract_citations(&response);
assert_eq!(citations.len(), 1);
assert_eq!(citations[0], "https://valid.com/");
@ -510,14 +637,24 @@ mod tests {
Mock::given(method("POST"))
.and(path("/responses"))
.and(header("Authorization", "Bearer static-key-from-config"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!(
{ "id" : "resp_test", "object" : "response", "created_at" :
1234567890, "status" : "completed", "model" : "test-model",
"output" : [{ "type" : "message", "id" : "msg_1", "status" :
"completed", "role" : "assistant", "content" : [{ "type" :
"output_text", "text" : "search result", "annotations" : []
}] }] }
)))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "resp_test",
"object": "response",
"created_at": 1234567890,
"status": "completed",
"model": "test-model",
"output": [{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "assistant",
"content": [{
"type": "output_text",
"text": "search result",
"annotations": []
}]
}]
})))
.mount(&server)
.await;
let config = WebSearchConfig::Enabled {
@ -550,14 +687,24 @@ mod tests {
Mock::given(method("POST"))
.and(path("/responses"))
.and(header("Authorization", "Bearer fresh-key-from-provider"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!(
{ "id" : "resp_test", "object" : "response", "created_at" :
1234567890, "status" : "completed", "model" : "test-model",
"output" : [{ "type" : "message", "id" : "msg_1", "status" :
"completed", "role" : "assistant", "content" : [{ "type" :
"output_text", "text" : "fresh result", "annotations" : [] }]
}] }
)))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "resp_test",
"object": "response",
"created_at": 1234567890,
"status": "completed",
"model": "test-model",
"output": [{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "assistant",
"content": [{
"type": "output_text",
"text": "fresh result",
"annotations": []
}]
}]
})))
.mount(&server)
.await;
let config = WebSearchConfig::Enabled {
@ -577,13 +724,28 @@ mod tests {
}
#[test]
fn test_extract_citations_no_annotations() {
let response = response_from_json(serde_json::json!(
{ "id" : "resp_test", "object" : "response", "created_at" : 1234567890,
"status" : "completed", "model" : "test-model", "output" : [{ "type" :
"message", "id" : "msg_1", "status" : "completed", "role" : "assistant",
"content" : [{ "type" : "output_text", "text" :
"Plain text with no annotations", "annotations" : [] }] }] }
));
let response = response_from_json(serde_json::json!({
"id": "resp_test",
"object": "response",
"created_at": 1234567890,
"status": "completed",
"model": "test-model",
"output": [
{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "Plain text with no annotations",
"annotations": []
}
]
}
]
}));
let citations = extract_citations(&response);
assert!(citations.is_empty());
}