From 9ae04f14e3b4fe67c35053198e5b6162e9b6e314 Mon Sep 17 00:00:00 2001 From: Nick Pismenkov <50764773+nickpismenkov@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:12:49 -0800 Subject: [PATCH] feat: restart (#531) * feat: restart * review fixes * add IRONCLAW_IN_DOCKER env variable * review fixes * fix tests * set default value as false --- .env.example | 7 + deploy/env.example | 9 + src/agent/agent_loop.rs | 13 +- src/agent/commands.rs | 72 ++++- src/agent/submission.rs | 8 + src/channels/web/server.rs | 20 ++ src/channels/web/static/app.js | 126 ++++++++ src/channels/web/static/index.html | 50 +++ src/channels/web/static/style.css | 278 +++++++++++++++++ src/tools/builtin/mod.rs | 2 + src/tools/builtin/restart.rs | 483 +++++++++++++++++++++++++++++ src/tools/registry.rs | 16 +- 12 files changed, 1075 insertions(+), 9 deletions(-) create mode 100644 src/tools/builtin/restart.rs diff --git a/.env.example b/.env.example index 64a688a8..9fe1f460 100644 --- a/.env.example +++ b/.env.example @@ -115,5 +115,12 @@ HEARTBEAT_NOTIFY_USER=default SAFETY_MAX_OUTPUT_LENGTH=100000 SAFETY_INJECTION_CHECK_ENABLED=true +# Restart Feature (Docker containers only) +# Set IRONCLAW_IN_DOCKER=true in the container entrypoint to enable the restart feature. +# Without this, the restart tool and /restart command will be disabled. +# IRONCLAW_IN_DOCKER=false +# IRONCLAW_RESTART_DELAY=5 # default wait before exit (seconds, range: 1-30) +# IRONCLAW_MAX_FAILURES=10 # max consecutive failures before container exits + # Logging RUST_LOG=ironclaw=debug,tower_http=debug diff --git a/deploy/env.example b/deploy/env.example index 45a17c9f..c982d9aa 100644 --- a/deploy/env.example +++ b/deploy/env.example @@ -24,6 +24,15 @@ GATEWAY_HOST=0.0.0.0 GATEWAY_PORT=3000 GATEWAY_AUTH_TOKEN=CHANGE_ME +# Restart Feature (Docker containers only) +# IMPORTANT: Set this in the container entrypoint or docker-compose to enable restart. +# The Docker entrypoint loop monitors exit codes: +# - Exit code 0 = clean restart: reset failure counter, wait IRONCLAW_RESTART_DELAY, restart +# - Exit code ≠ 0 = failure: increment counter, exit after IRONCLAW_MAX_FAILURES +IRONCLAW_IN_DOCKER=false +IRONCLAW_RESTART_DELAY=5 # seconds to wait before restarting (range: 1-30) +IRONCLAW_MAX_FAILURES=10 # max consecutive failures before container exits + # Disabled for initial deploy SANDBOX_ENABLED=false HEARTBEAT_ENABLED=false diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index e7b0dea1..6c8680d0 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -635,6 +635,10 @@ impl Agent { // Parse submission type first let mut submission = SubmissionParser::parse(&message.content); + tracing::debug!( + "[agent_loop] Parsed submission: {:?}", + std::any::type_name_of_val(&submission) + ); // Hook: BeforeInbound — allow hooks to modify or reject user input if let Submission::UserInput { ref content } = submission { @@ -719,7 +723,14 @@ impl Agent { .await } Submission::SystemCommand { command, args } => { - self.handle_system_command(&command, &args).await + tracing::debug!( + "[agent_loop] SystemCommand: command={}, channel={}", + command, + message.channel + ); + // Authorization checks (including restart channel check) are enforced in handle_system_command + self.handle_system_command(&command, &args, &message.channel) + .await } Submission::Undo => self.process_undo(session, thread_id).await, Submission::Redo => self.process_redo(session, thread_id).await, diff --git a/src/agent/commands.rs b/src/agent/commands.rs index 2aab2e4e..f0b79896 100644 --- a/src/agent/commands.rs +++ b/src/agent/commands.rs @@ -68,7 +68,10 @@ impl Agent { self.handle_help_job(&message.user_id, &job_id).await? } MessageIntent::Command { command, args } => { - match self.handle_command(&command, &args).await? { + match self + .handle_command(&command, &args, &message.channel) + .await? + { Some(s) => s, None => return Ok(SubmissionResult::Ok { message: None }), // Shutdown signal } @@ -466,6 +469,7 @@ impl Agent { &self, command: &str, args: &[String], + channel: &str, ) -> Result { match command { "help" => Ok(SubmissionResult::response(concat!( @@ -501,12 +505,75 @@ impl Agent { " /heartbeat Run heartbeat check\n", " /summarize Summarize current thread\n", " /suggest Suggest next steps\n", + " /restart Gracefully restart the process\n", "\n", " /quit Exit", ))), "ping" => Ok(SubmissionResult::response("pong!")), + "restart" => { + tracing::info!("[commands::restart] Restart command received"); + // Channel authorization check: restart is only available via web interface + if channel != "gateway" { + tracing::warn!( + "[commands::restart] Restart rejected: not from gateway channel (from: {})", + channel + ); + return Ok(SubmissionResult::error( + "Restart is only available through the web interface with explicit user confirmation. \ + Use the Restart button in the UI." + .to_string(), + )); + } + // Environment check: restart is only available in Docker containers + let in_docker = std::env::var("IRONCLAW_IN_DOCKER") + .map(|v| v.to_lowercase() == "true") + .unwrap_or(false); + + tracing::debug!("[commands::restart] IRONCLAW_IN_DOCKER={}", in_docker); + + if !in_docker { + tracing::warn!( + "[commands::restart] Restart rejected: not in Docker environment" + ); + return Ok(SubmissionResult::error( + "Restart is not available in this environment. \ + The IRONCLAW_IN_DOCKER environment variable must be set to 'true' for Docker deployments." + .to_string(), + )); + } + + // Execute restart tool directly (don't dispatch as a job for LLM planning) + // This ensures the tool runs immediately without LLM involvement + use crate::tools::Tool; + let tool = crate::tools::builtin::RestartTool; + let params = serde_json::json!({}); + + // Create a minimal JobContext for the tool + let dummy_ctx = + crate::context::JobContext::with_user("system", "Restart", "Graceful restart"); + + match tool.execute(params, &dummy_ctx).await { + Ok(output) => { + tracing::info!("[commands::restart] RestartTool executed successfully"); + // Extract text from the ToolOutput result + let response = match output.result { + serde_json::Value::String(s) => s, + _ => output.result.to_string(), + }; + Ok(SubmissionResult::response(response)) + } + Err(e) => { + tracing::error!( + "[commands::restart] RestartTool execution failed: {:?}", + e + ); + Ok(SubmissionResult::error(format!("Restart failed: {}", e))) + } + } + } + "version" => Ok(SubmissionResult::response(format!( "{} v{}", env!("CARGO_PKG_NAME"), @@ -744,10 +811,11 @@ impl Agent { &self, command: &str, args: &[String], + channel: &str, ) -> Result, Error> { // System commands are now handled directly via Submission::SystemCommand, // but the router may still send us unknown /commands. - match self.handle_system_command(command, args).await? { + match self.handle_system_command(command, args, channel).await? { SubmissionResult::Response { content } => Ok(Some(content)), SubmissionResult::Ok { message } => Ok(message), SubmissionResult::Error { message } => Ok(Some(format!("Error: {}", message))), diff --git a/src/agent/submission.rs b/src/agent/submission.rs index cdaba936..46336133 100644 --- a/src/agent/submission.rs +++ b/src/agent/submission.rs @@ -14,6 +14,7 @@ impl SubmissionParser { pub fn parse(content: &str) -> Submission { let trimmed = content.trim(); let lower = trimmed.to_lowercase(); + tracing::debug!("[SubmissionParser::parse] Parsing input: {:?}", trimmed); // Control commands (exact match or prefix) if lower == "/undo" { @@ -91,6 +92,13 @@ impl SubmissionParser { args: vec![], }; } + if lower == "/restart" { + tracing::debug!("[SubmissionParser::parse] Recognized /restart command"); + return Submission::SystemCommand { + command: "restart".to_string(), + args: vec![], + }; + } if lower.starts_with("/model") { let args: Vec = trimmed .split_whitespace() diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 9fe3ac3d..1cde7e70 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -606,6 +606,12 @@ async fn chat_send_handler( State(state): State>, Json(req): Json, ) -> Result<(StatusCode, Json), (StatusCode, String)> { + tracing::debug!( + "[chat_send_handler] Received message: content={:?}, thread_id={:?}", + req.content, + req.thread_id + ); + if !state.chat_rate_limiter.check() { return Err(( StatusCode::TOO_MANY_REQUESTS, @@ -621,6 +627,11 @@ async fn chat_send_handler( } let msg_id = msg.id; + tracing::debug!( + "[chat_send_handler] Created message id={}, content={:?}", + msg_id, + req.content + ); let tx_guard = state.msg_tx.read().await; let tx = tx_guard.as_ref().ok_or(( @@ -628,6 +639,7 @@ async fn chat_send_handler( "Channel not started".to_string(), ))?; + tracing::debug!("[chat_send_handler] Sending message through channel"); tx.send(msg).await.map_err(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, @@ -635,6 +647,8 @@ async fn chat_send_handler( ) })?; + tracing::debug!("[chat_send_handler] Message sent successfully, returning 202 ACCEPTED"); + Ok(( StatusCode::ACCEPTED, Json(SendMessageResponse { @@ -2300,11 +2314,16 @@ async fn gateway_status_handler( (None, None, None) }; + let restart_enabled = std::env::var("IRONCLAW_IN_DOCKER") + .map(|v| v.to_lowercase() == "true") + .unwrap_or(false); + Json(GatewayStatusResponse { sse_connections, ws_connections, total_connections: sse_connections + ws_connections, uptime_secs, + restart_enabled, daily_cost, actions_this_hour, model_usage, @@ -2325,6 +2344,7 @@ struct GatewayStatusResponse { ws_connections: u64, total_connections: u64, uptime_secs: u64, + restart_enabled: bool, #[serde(skip_serializing_if = "Option::is_none")] daily_cost: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index 1d956cf3..fb16ac3c 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -133,6 +133,110 @@ function apiFetch(path, options) { }); } +// --- Restart Feature --- + +let isRestarting = false; // Track if we're currently restarting +let restartEnabled = false; // Track if restart is available in this deployment + +function triggerRestart() { + if (!currentThreadId) { + alert('Please start a conversation first'); + return; + } + + // Show the confirmation modal + const confirmModal = document.getElementById('restart-confirm-modal'); + confirmModal.style.display = 'flex'; +} + +function confirmRestart() { + if (!currentThreadId) { + alert('Please start a conversation first'); + return; + } + + // Hide confirmation modal + const confirmModal = document.getElementById('restart-confirm-modal'); + confirmModal.style.display = 'none'; + + const restartBtn = document.getElementById('restart-btn'); + const restartIcon = document.getElementById('restart-icon'); + + // Mark as restarting + isRestarting = true; + restartBtn.disabled = true; + if (restartIcon) restartIcon.classList.add('spinning'); + + // Show progress modal + const loaderEl = document.getElementById('restart-loader'); + loaderEl.style.display = 'flex'; + + // Send restart command via chat + console.log('[confirmRestart] Sending /restart command to server'); + apiFetch('/api/chat/send', { + method: 'POST', + body: { + content: '/restart', + thread_id: currentThreadId, + }, + }) + .then((response) => { + console.log('[confirmRestart] API call succeeded, response:', response); + }) + .catch((err) => { + console.error('[confirmRestart] Restart request failed:', err); + addMessage('system', 'Restart failed: ' + err.message); + isRestarting = false; + restartBtn.disabled = false; + if (restartIcon) restartIcon.classList.remove('spinning'); + loaderEl.style.display = 'none'; + }); +} + +function cancelRestart() { + const confirmModal = document.getElementById('restart-confirm-modal'); + confirmModal.style.display = 'none'; +} + +function tryShowRestartModal() { + // Defensive callback for when restart is detected in messages. + if (!isRestarting) { + isRestarting = true; + const restartBtn = document.getElementById('restart-btn'); + const restartIcon = document.getElementById('restart-icon'); + restartBtn.disabled = true; + if (restartIcon) restartIcon.classList.add('spinning'); + + // Show progress modal + const loaderEl = document.getElementById('restart-loader'); + loaderEl.style.display = 'flex'; + } +} + +function updateRestartButtonVisibility() { + const restartBtn = document.getElementById('restart-btn'); + if (restartBtn) { + restartBtn.style.display = restartEnabled ? 'block' : 'none'; + } +} + +function startGatewayStatusPolling() { + fetchGatewayStatus(); + // Poll every 5 seconds + setInterval(fetchGatewayStatus, 5000); +} + +function fetchGatewayStatus() { + apiFetch('/api/gateway/status') + .then((data) => { + restartEnabled = data.restart_enabled || false; + updateRestartButtonVisibility(); + }) + .catch((err) => { + console.warn('[gateway status] Failed to fetch:', err); + }); +} + // --- SSE --- function connectSSE() { @@ -143,6 +247,18 @@ function connectSSE() { eventSource.onopen = () => { document.getElementById('sse-dot').classList.remove('disconnected'); document.getElementById('sse-status').textContent = 'Connected'; + + // If we were restarting, close the modal and reset button now that server is back + if (isRestarting) { + const loaderEl = document.getElementById('restart-loader'); + if (loaderEl) loaderEl.style.display = 'none'; + const restartBtn = document.getElementById('restart-btn'); + const restartIcon = document.getElementById('restart-icon'); + if (restartBtn) restartBtn.disabled = false; + if (restartIcon) restartIcon.classList.remove('spinning'); + isRestarting = false; + } + if (sseHasConnectedBefore && currentThreadId) { finalizeActivityGroup(); loadHistory(); @@ -163,6 +279,11 @@ function connectSSE() { enableChatInput(); // Refresh thread list so new titles appear after first message loadThreads(); + + // Show restart modal if the response indicates restart was initiated + if (data.content && data.content.toLowerCase().includes('restart initiated')) { + setTimeout(() => tryShowRestartModal(), 500); + } }); eventSource.addEventListener('thinking', (e) => { @@ -181,6 +302,11 @@ function connectSSE() { const data = JSON.parse(e.data); if (!isCurrentThread(data.thread_id)) return; completeToolCard(data.name, data.success, data.error, data.parameters); + + // Show restart modal only when the restart tool succeeds + if (data.name.toLowerCase() === 'restart' && data.success) { + setTimeout(() => tryShowRestartModal(), 500); + } }); eventSource.addEventListener('tool_result', (e) => { diff --git a/src/channels/web/static/index.html b/src/channels/web/static/index.html index 600c533e..1d232d17 100644 --- a/src/channels/web/static/index.html +++ b/src/channels/web/static/index.html @@ -33,6 +33,48 @@ + + + + + +
@@ -57,6 +99,14 @@ Connected
+ diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index d0bf514e..ead9cec8 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -259,6 +259,284 @@ body { white-space: nowrap; } +/* Restart Button */ +.restart-btn { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0.75rem; + border-radius: 0.5rem; + font-size: 0.8rem; + border: 1px solid; + border-color: #00d894; + color: #00d894; + background-color: transparent; + cursor: pointer; + transition: color 150ms, background-color 150ms, border-color 150ms; +} + +.restart-btn:hover:not(:disabled) { + background-color: rgba(0, 216, 148, 0.1); +} + +.restart-btn:disabled { + border-color: #333; + color: #666; + cursor: not-allowed; +} + +.restart-btn:disabled:hover { + background-color: transparent; +} + +.restart-btn svg { + flex-shrink: 0; + width: 13px; + height: 13px; +} + +.restart-btn svg.spinning { + animation: spin-icon 1s linear infinite; +} + +@keyframes spin-icon { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* Restart Loader Overlay */ +.restart-loader { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; +} + +.restart-loader-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + z-index: -1; +} + +.restart-loader-content { + position: relative; + z-index: 10000; + background-color: #1a1a1a; + border: 1px solid #333; + border-radius: 0.75rem; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + width: 100%; + max-width: 28rem; + margin: 0 1rem; + overflow: hidden; + padding: 1.25rem; +} + +.restart-spinner { + display: none; +} + +.restart-loader-text { + padding: 0; +} + +.restart-title { + color: #e0e0e0; + font-size: 0.85rem; + margin-bottom: 1rem; + margin-top: 0; +} + +.restart-subtitle { + display: none; +} + +/* Restart Modal (Confirmation) */ +.restart-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; +} + +.restart-modal-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); +} + +.restart-modal-content { + position: relative; + z-index: 10000; + background-color: #1a1a1a; + border: 1px solid #333; + border-radius: 0.75rem; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + width: 100%; + max-width: 28rem; + margin: 0 1rem; + overflow: hidden; +} + +.restart-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.25rem; + border-bottom: 1px solid #2a2a2a; +} + +.restart-modal-header h2 { + color: #e0e0e0; + font-size: 0.95rem; + margin: 0; +} + +.restart-modal-close { + color: #888; + padding: 0.25rem; + border-radius: 0.25rem; + background-color: transparent; + border: none; + cursor: pointer; + transition: color 150ms, background-color 150ms; + display: flex; + align-items: center; + justify-content: center; +} + +.restart-modal-close:hover { + color: #ccc; + background-color: #2a2a2a; +} + +.restart-modal-body { + padding: 1.25rem; +} + +.restart-modal-description { + color: #aaa; + font-size: 0.85rem; + margin: 0; +} + +.restart-modal-warning { + margin-top: 1rem; + background-color: #1e1400; + border: 1px solid #3a2a00; + border-radius: 0.5rem; + padding: 0.75rem 1rem; +} + +.restart-modal-warning p { + color: #facc15; + font-size: 0.8rem; + margin: 0; +} + +.restart-modal-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.75rem; + padding: 1rem 1.25rem; + border-top: 1px solid #2a2a2a; +} + +.restart-modal-btn { + padding: 0.5rem 1rem; + border-radius: 0.5rem; + font-size: 0.85rem; + border: none; + cursor: pointer; + transition: background-color 150ms; +} + +.restart-modal-btn.cancel { + color: #ccc; + background-color: transparent; +} + +.restart-modal-btn.cancel:hover { + background-color: #2a2a2a; +} + +.restart-modal-btn.confirm { + background-color: #00D894; + color: #111; +} + +.restart-modal-btn.confirm:hover { + background-color: #00be82; +} + +/* Progress Bar for Restart */ +.restart-progress-bar { + width: 100%; + height: 0.375rem; + background-color: #2a2a2a; + border-radius: 9999px; + overflow: hidden; +} + +.restart-progress-fill { + height: 100%; + border-radius: 9999px; + background-color: #00D894; + width: 40%; + animation: indeterminate 1.5s ease-in-out infinite; +} + +@keyframes indeterminate { + 0% { + margin-left: 0; + width: 40%; + } + 50% { + margin-left: 60%; + width: 40%; + } + 100% { + margin-left: 0; + width: 40%; + } +} + +.restart-modal-info { + color: #666; + font-size: 0.8rem; + margin-top: 1.25rem; + margin-bottom: 0; +} + +.restart-modal-info a { + color: #00D894; + text-decoration: none; +} + +.restart-modal-info a:hover { + text-decoration: underline; +} + .tee-popover { display: none; position: absolute; diff --git a/src/tools/builtin/mod.rs b/src/tools/builtin/mod.rs index d0d6f2c1..703f972a 100644 --- a/src/tools/builtin/mod.rs +++ b/src/tools/builtin/mod.rs @@ -9,6 +9,7 @@ mod json; mod memory; mod message; pub mod path_utils; +mod restart; pub mod routine; pub mod secrets_tools; pub(crate) mod shell; @@ -28,6 +29,7 @@ pub use job::{ pub use json::JsonTool; pub use memory::{MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTool}; pub use message::MessageTool; +pub use restart::RestartTool; pub use routine::{ RoutineCreateTool, RoutineDeleteTool, RoutineHistoryTool, RoutineListTool, RoutineUpdateTool, }; diff --git a/src/tools/builtin/restart.rs b/src/tools/builtin/restart.rs new file mode 100644 index 00000000..8f2bc906 --- /dev/null +++ b/src/tools/builtin/restart.rs @@ -0,0 +1,483 @@ +//! Restart tool for graceful process restart. +//! +//! ## Architecture +//! +//! IronClaw runs inside a Docker container with an entrypoint loop that monitors exit codes: +//! - **Exit code 0** (clean): Reset failure counter, wait `IRONCLAW_RESTART_DELAY` (default 5s), restart +//! - **Exit code ≠ 0** (failure): Increment failure counter, exit after `IRONCLAW_MAX_FAILURES` (default 10) +//! +//! This tool triggers a restart by calling `std::process::exit(0)` after a brief delay, allowing +//! the HTTP response to be flushed before the process terminates. The entrypoint loop then +//! detects the clean exit and automatically restarts the process. +//! +//! ## Security +//! +//! - **Approval Model:** User approval happens at the command level via web modal confirmation, +//! not at tool execution level. This allows approved commands to execute in autonomous jobs. +//! - **Web-Only Access:** The `/restart` command only works via the web gateway (enforced in commands.rs) +//! - **Parameter Validation:** Delay clamped to 1-30 seconds +//! +//! ## Known Limitations +//! +//! - Hard exit without graceful shutdown (no destructor cleanup, no RwLock drains) +//! - In-flight jobs are paused during restart and resumed by the entrypoint +//! - Future: Implement graceful shutdown with CancellationToken for proper resource cleanup + +use async_trait::async_trait; +use std::time::Duration; + +use crate::context::JobContext; +#[allow(unused_imports)] +use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput}; + +/// Tool for triggering a graceful process restart via exit code 0. +/// +/// This tool signals the Docker entrypoint loop to restart the process by exiting cleanly +/// (exit code 0). User approval happens at the command level (via the web modal confirmation), +/// not at tool execution level. The `/restart` command is only callable via the web gateway +/// interface to prevent unauthorized restarts. +pub struct RestartTool; + +#[async_trait] +impl Tool for RestartTool { + fn name(&self) -> &str { + "restart" + } + + fn description(&self) -> &str { + "Restart the IronClaw agent process. The process exits cleanly (code 0) and the \ + container entrypoint loop restarts it automatically within a few seconds." + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "delay_secs": { + "type": "integer", + "description": "Seconds to wait before exiting (default: 2, min: 1, max: 30)", + "minimum": 1, + "maximum": 30 + } + } + }) + } + + async fn execute( + &self, + params: serde_json::Value, + _ctx: &JobContext, + ) -> Result { + tracing::info!("[RestartTool::execute] Restart tool invoked"); + let start = std::time::Instant::now(); + + // Check if running inside a Docker container via IRONCLAW_IN_DOCKER env var. + // The Docker entrypoint sets this to "true". For local development, it's unset or "false". + // The entrypoint restart loop only works inside a Docker container (ironclaw-worker). + let in_docker = std::env::var("IRONCLAW_IN_DOCKER") + .map(|v| v.to_lowercase() == "true") + .unwrap_or(false); + + tracing::debug!("[RestartTool::execute] IRONCLAW_IN_DOCKER={}", in_docker); + + if !in_docker { + tracing::error!("[RestartTool::execute] Not in Docker, rejecting restart"); + return Err(ToolError::ExecutionFailed( + "Restart is only available when running inside the Docker container. \ + For local development, please restart IronClaw manually." + .to_string(), + )); + } + + // Extract delay_secs parameter, defaulting to 2 seconds + let delay = params + .get("delay_secs") + .and_then(|v| v.as_u64()) + .unwrap_or(2) + // Validate delay against schema bounds (1-30 seconds) + .clamp(1, 30); + tracing::info!("[RestartTool::execute] Delay set to {} seconds", delay); + + // Spawn a background task so the response is flushed before exit. + // We use std::process::exit(0) to trigger a Docker container restart: + // + // - The ironclaw-worker Docker container runs an entrypoint loop that monitors + // the exit code of the `ironclaw run` process: + // * Exit code 0 = clean restart: reset failure counter, wait IRONCLAW_RESTART_DELAY + // (default 5s), then restart the process + // * Exit code ≠ 0 = failure: increment counter, exit after IRONCLAW_MAX_FAILURES + // (default 10 failures) + // + // - std::process::exit(0) is a hard exit (no destructors, no graceful shutdown). + // This is intentional because: + // 1. The HTTP response must be sent before exit (hence tokio::spawn + delay) + // 2. In-flight jobs are paused/resumed by the entrypoint loop + // 3. Database connections are pooled and reopened on restart + // 4. The brief delay allows the response to flush before termination + // + // - Future improvement: implement graceful shutdown with CancellationToken + // to properly drain Axum, close DB connections, and checkpoint jobs. + // Check if restart is disabled (e.g., in tests). This allows tests to verify + // parameter parsing and output without actually terminating the process. + let restart_disabled = std::env::var("IRONCLAW_DISABLE_RESTART") + .map(|v| { + let v = v.to_lowercase(); + v == "1" || v == "true" + }) + .unwrap_or(false); + + tracing::info!( + "[RestartTool::execute] Spawning background task to exit in {} seconds (disabled={})", + delay, + restart_disabled + ); + tokio::spawn(async move { + tracing::info!("[RestartTool] Sleeping for {} seconds before exit", delay); + tokio::time::sleep(Duration::from_secs(delay)).await; + if !restart_disabled { + tracing::warn!("[RestartTool] Calling std::process::exit(0) NOW"); + std::process::exit(0); + } else { + tracing::info!( + "[RestartTool] Exit disabled (IRONCLAW_DISABLE_RESTART set), skipping std::process::exit(0)" + ); + } + }); + + let msg = format!( + "Restarting in {delay} second(s). The process will exit cleanly and the \ + entrypoint restart loop will bring IronClaw back online." + ); + tracing::info!("[RestartTool::execute] Returning success response: {}", msg); + Ok(ToolOutput::text(msg, start.elapsed())) + } + + fn requires_sanitization(&self) -> bool { + false + } + + // NOTE: Approval is handled at the command level (/restart via web modal confirmation), + // not at the tool execution level. By the time the tool executes, the user has already + // confirmed via the web interface. So we don't require approval here. + // This allows the tool to execute in autonomous jobs created from approved commands. +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Helper to simulate Docker environment for testing + fn enable_docker_env() { + unsafe { + std::env::set_var("IRONCLAW_IN_DOCKER", "true"); + } + } + + #[test] + fn test_restart_tool_approval_handled_at_command_level() { + // Approval is handled at the /restart command level (web modal confirmation), + // not at tool execution. Tool execution approval is for user-interactive approvals + // that happen during job execution. The restart confirmation modal provides that gate. + let tool = RestartTool; + let approval = tool.requires_approval(&serde_json::json!({})); + // Default (Never) allows tool to execute in autonomous jobs created from approved commands + assert!(matches!(approval, ApprovalRequirement::Never)); + } + + #[test] + fn test_restart_tool_name() { + let tool = RestartTool; + assert_eq!(tool.name(), "restart"); + } + + #[test] + fn test_restart_tool_parameters_schema() { + let tool = RestartTool; + let schema = tool.parameters_schema(); + + // Verify schema has delay_secs property with bounds + let props = schema.get("properties").unwrap(); + assert!(props.get("delay_secs").is_some()); + + let delay_schema = props.get("delay_secs").unwrap(); + assert_eq!(delay_schema.get("minimum").unwrap().as_u64().unwrap(), 1); + assert_eq!(delay_schema.get("maximum").unwrap().as_u64().unwrap(), 30); + } + + #[test] + fn test_restart_tool_requires_sanitization() { + let tool = RestartTool; + assert!(!tool.requires_sanitization()); + } + + #[tokio::test] + async fn test_restart_tool_delay_parameter_validation() { + enable_docker_env(); + let tool = RestartTool; + let ctx = crate::context::JobContext::new("test", "test restart"); + + // Test with valid delay + let result = tool + .execute(serde_json::json!({"delay_secs": 5}), &ctx) + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + let text = output.result.as_str().expect("result should be a string"); + assert!(text.contains("Restarting in 5 second(s)")); + + // Test with no delay parameter (should use default 2) + let result = tool.execute(serde_json::json!({}), &ctx).await; + assert!(result.is_ok()); + let output = result.unwrap(); + let text = output.result.as_str().expect("result should be a string"); + assert!(text.contains("Restarting in 2 second(s)")); + } + + #[tokio::test] + async fn test_restart_tool_delay_clamping() { + enable_docker_env(); + let tool = RestartTool; + let ctx = crate::context::JobContext::new("test", "test restart"); + + // Test with too small delay (should clamp to 1) + let result = tool + .execute(serde_json::json!({"delay_secs": 0}), &ctx) + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + let text = output.result.as_str().expect("result should be a string"); + assert!(text.contains("Restarting in 1 second(s)")); + + // Test with too large delay (should clamp to 30) + let result = tool + .execute(serde_json::json!({"delay_secs": 100}), &ctx) + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + let text = output.result.as_str().expect("result should be a string"); + assert!(text.contains("Restarting in 30 second(s)")); + } + + #[test] + fn test_restart_tool_description() { + let tool = RestartTool; + let desc = tool.description(); + assert!(desc.contains("Restart")); + assert!(desc.contains("IronClaw")); + assert!(desc.contains("exits cleanly")); + assert!(desc.contains("code 0")); + } + + #[test] + fn test_restart_tool_schema_completeness() { + let tool = RestartTool; + let schema = tool.parameters_schema(); + + // Verify schema structure + assert_eq!(schema.get("type").unwrap().as_str().unwrap(), "object"); + + let props = schema.get("properties").unwrap(); + assert!(props.is_object()); + + let delay_schema = props.get("delay_secs").unwrap(); + assert_eq!( + delay_schema.get("type").unwrap().as_str().unwrap(), + "integer" + ); + assert!(delay_schema.get("description").is_some()); + } + + #[tokio::test] + async fn test_restart_tool_boundary_values() { + enable_docker_env(); + let tool = RestartTool; + let ctx = crate::context::JobContext::new("test", "test restart"); + + // Test minimum boundary (exactly 1) + let result = tool + .execute(serde_json::json!({"delay_secs": 1}), &ctx) + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + let text = output.result.as_str().unwrap(); + assert!(text.contains("Restarting in 1 second(s)")); + + // Test maximum boundary (exactly 30) + let result = tool + .execute(serde_json::json!({"delay_secs": 30}), &ctx) + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + let text = output.result.as_str().unwrap(); + assert!(text.contains("Restarting in 30 second(s)")); + + // Test middle value + let result = tool + .execute(serde_json::json!({"delay_secs": 15}), &ctx) + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + let text = output.result.as_str().unwrap(); + assert!(text.contains("Restarting in 15 second(s)")); + } + + #[tokio::test] + async fn test_restart_tool_invalid_parameter_types() { + enable_docker_env(); + let tool = RestartTool; + let ctx = crate::context::JobContext::new("test", "test restart"); + + // String instead of integer - should use default + let result = tool + .execute(serde_json::json!({"delay_secs": "5"}), &ctx) + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + let text = output.result.as_str().unwrap(); + assert!(text.contains("Restarting in 2 second(s)")); // Falls back to default + + // Null value - should use default + let result = tool + .execute(serde_json::json!({"delay_secs": null}), &ctx) + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + let text = output.result.as_str().unwrap(); + assert!(text.contains("Restarting in 2 second(s)")); + + // Float value - should use default (as_u64 fails on floats) + let result = tool + .execute(serde_json::json!({"delay_secs": 5.5}), &ctx) + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + let text = output.result.as_str().unwrap(); + assert!(text.contains("Restarting in 2 second(s)")); + } + + #[tokio::test] + async fn test_restart_tool_output_structure() { + enable_docker_env(); + let tool = RestartTool; + let ctx = crate::context::JobContext::new("test", "test restart"); + + let result = tool + .execute(serde_json::json!({"delay_secs": 5}), &ctx) + .await; + + assert!(result.is_ok()); + let output = result.unwrap(); + + // Verify ToolOutput structure + assert!(output.result.is_string()); + assert!(output.duration.as_secs() == 0); // Should be nearly instant + assert!(output.cost.is_none()); // No cost tracking for restart + assert!(output.raw.is_none()); // No raw output stored + } + + #[tokio::test] + async fn test_restart_tool_extra_parameters_ignored() { + enable_docker_env(); + let tool = RestartTool; + let ctx = crate::context::JobContext::new("test", "test restart"); + + // Extra parameters should be ignored + let result = tool + .execute( + serde_json::json!({ + "delay_secs": 5, + "extra_field": "should be ignored", + "another": 123 + }), + &ctx, + ) + .await; + + assert!(result.is_ok()); + let output = result.unwrap(); + let text = output.result.as_str().unwrap(); + assert!(text.contains("Restarting in 5 second(s)")); + } + + #[tokio::test] + async fn test_restart_tool_negative_numbers() { + enable_docker_env(); + let tool = RestartTool; + let ctx = crate::context::JobContext::new("test", "test restart"); + + // Negative number should clamp to 1 + let result = tool + .execute(serde_json::json!({"delay_secs": -5}), &ctx) + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + let text = output.result.as_str().unwrap(); + // as_u64() on negative number returns None, so falls to default 2 + assert!(text.contains("Restarting in 2 second(s)")); + } + + #[tokio::test] + async fn test_restart_tool_very_large_numbers() { + enable_docker_env(); + let tool = RestartTool; + let ctx = crate::context::JobContext::new("test", "test restart"); + + // Very large number should clamp to 30 + let result = tool + .execute(serde_json::json!({"delay_secs": u64::MAX}), &ctx) + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + let text = output.result.as_str().unwrap(); + assert!(text.contains("Restarting in 30 second(s)")); + } + + #[tokio::test] + async fn test_restart_tool_empty_object() { + enable_docker_env(); + let tool = RestartTool; + let ctx = crate::context::JobContext::new("test", "test restart"); + + // Empty object params should use all defaults + let result = tool.execute(serde_json::json!({}), &ctx).await; + assert!(result.is_ok()); + let output = result.unwrap(); + let text = output.result.as_str().unwrap(); + assert!(text.contains("Restarting in 2 second(s)")); + assert!(text.contains("exit cleanly")); + assert!(text.contains("entrypoint restart loop")); + } + + #[test] + fn test_restart_tool_approval_consistent_regardless_of_params() { + let tool = RestartTool; + + // Approval requirement should be the same regardless of params + let approval1 = tool.requires_approval(&serde_json::json!({"delay_secs": 5})); + let approval2 = tool.requires_approval(&serde_json::json!({"delay_secs": 100})); + let approval3 = tool.requires_approval(&serde_json::json!({})); + + // All should return the default (Never) since approval happens at command level + assert!(matches!(approval1, ApprovalRequirement::Never)); + assert!(matches!(approval2, ApprovalRequirement::Never)); + assert!(matches!(approval3, ApprovalRequirement::Never)); + } + + #[test] + fn test_restart_tool_requires_docker_environment() { + // Test that restart is rejected when not in Docker (IRONCLAW_IN_DOCKER not set or false) + // Uses sync test to avoid async/env var ordering issues with test parallelization. + let in_docker = std::env::var("IRONCLAW_IN_DOCKER") + .map(|v| v.to_lowercase() == "true") + .unwrap_or(false); + + // Verify logic: when not in Docker, env var should be false/unset + if !in_docker { + // Simulating what the tool would do when IRONCLAW_IN_DOCKER is not set + assert!( + !in_docker, + "Test environment should have IRONCLAW_IN_DOCKER unset or false" + ); + } + } +} diff --git a/src/tools/registry.rs b/src/tools/registry.rs index 56719ca6..3775c480 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -68,6 +68,8 @@ const PROTECTED_TOOL_NAMES: &[&str] = &[ "skill_install", "skill_remove", "message", + "web_fetch", + "restart", ]; /// Registry of available tools. @@ -155,7 +157,8 @@ impl ToolRegistry { /// Get a tool by name. pub async fn get(&self, name: &str) -> Option> { - self.tools.read().await.get(name).cloned() + let tools = self.tools.read().await; + tools.get(name).map(Arc::clone) } /// Check if a tool exists. @@ -209,11 +212,12 @@ impl ToolRegistry { let tools = self.tools.read().await; names .iter() - .filter_map(|name| tools.get(*name)) - .map(|tool| ToolDefinition { - name: tool.name().to_string(), - description: tool.description().to_string(), - parameters: tool.parameters_schema(), + .filter_map(|name| { + tools.get(*name).map(|tool| ToolDefinition { + name: tool.name().to_string(), + description: tool.description().to_string(), + parameters: tool.parameters_schema(), + }) }) .collect() }