diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml new file mode 100644 index 0000000..0926b38 --- /dev/null +++ b/.github/workflows/opencode-review.yml @@ -0,0 +1,26 @@ +name: opencode-review + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +jobs: + review: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + pull-requests: write + issues: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Run opencode review + uses: anomalyco/opencode/github@latest + env: + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + with: + model: opencode-go/qwen3.8-flash diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml new file mode 100644 index 0000000..956ff11 --- /dev/null +++ b/.github/workflows/opencode.yml @@ -0,0 +1,33 @@ +name: opencode + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + +jobs: + opencode: + if: | + contains(github.event.comment.body, ' /oc') || + startsWith(github.event.comment.body, '/oc') || + contains(github.event.comment.body, ' /opencode') || + startsWith(github.event.comment.body, '/opencode') + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + pull-requests: write + issues: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Run opencode + uses: anomalyco/opencode/github@latest + env: + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + with: + model: opencode-go/qwen3.8-flash diff --git a/CLAUDE.md b/CLAUDE.md index c6bdf3e..0027fff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -141,11 +141,7 @@ All jobs use `dtolnay/rust-toolchain@stable` and `Swatinem/rust-cache@v2` for ca ## Testing -No automated tests exist yet. When adding tests: - -- Place unit tests in `#[cfg(test)] mod tests` blocks within each source file -- Integration tests go in a top-level `tests/` directory -- The sandbox path validation logic in `tools.rs` and message splitting in `bot.rs` are good candidates for unit tests +Unit tests live in `#[cfg(test)] mod tests` within source files; integration tests in top-level `tests/`. Run `cargo test` (and `cargo clippy -- -D warnings`) before opening a PR. Memory knowledge/history/facts and supervisor backends already have unit coverage β€” extend those patterns when adding behavior. ## Common Tasks diff --git a/CONTEXT.md b/CONTEXT.md index 7993bb6..e831776 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -30,4 +30,24 @@ Entry in Tool Notifier showing: friendly tool name, optional args preview, statu Human-readable label for built-in tools (e.g., "πŸ’» Running a command" for `execute_command`). ### Args Preview -Truncated (60 chars), redacted JSON args shown in Tool Notifier. Only in Verbose mode. \ No newline at end of file +Truncated (60 chars), redacted JSON args shown in Verbose mode. + +--- + +## Memory + +### Knowledge +Current key-value snapshot the agent stores under `(category, key) β†’ value`. One live value per pair. +_Avoid_: fact (when meaning KV), memory entry, note + +### Knowledge History +Append-only archive of prior Knowledge values, written automatically on overwrite or delete. +_Avoid_: audit log, version log (generic) + +### Fact +Time-bounded triple `(entity, relation, value)` with `valid_from` / `valid_to`. At most one active value per `(entity, relation)` (`valid_to` null = still active). +_Avoid_: subject/object_value, triple, statement, knowledge (KV sense) + +### valid_from / valid_to +Inclusive start and exclusive-or-end bound of a Fact's validity window. `valid_to` null means still active. Stored as SQLite `datetime` TEXT. +_Avoid_: valid_until, effective_from, learned_at (provenance, not validity) diff --git a/config.example.toml b/config.example.toml index b3a519c..5e13e83 100644 --- a/config.example.toml +++ b/config.example.toml @@ -68,6 +68,8 @@ Be concise and helpful.""" # The LLM's persistent workspace (file/command tools are confined here). # Leave unset to use /workspace. Set an absolute path to override. # allowed_directory = "/absolute/path/to/workspace" +# Wall-clock limit for execute_command (seconds). 0 = no timeout. Default: 90. +# execute_timeout_secs = 90 [memory] # Path to the SQLite database file for persistent memory diff --git a/docs/adr/0004-temporal-facts-and-knowledge-history.md b/docs/adr/0004-temporal-facts-and-knowledge-history.md new file mode 100644 index 0000000..9abc9df --- /dev/null +++ b/docs/adr/0004-temporal-facts-and-knowledge-history.md @@ -0,0 +1,3 @@ +# Temporal Facts and Knowledge History + +Memory gains two layers: Knowledge History (auto-archive on `remember`/`forget` via SQLite triggers) and Facts (valid-time triples with `valid_from`/`valid_to`; `created_at` is audit only). Knowledge stays the live KV snapshot. Schema uses `entity`/`relation`/`value`/`valid_from`/`valid_to`. One active Fact per `(entity, relation)` (UNIQUE partial index); superseding values close the prior row; backdated inserts close against the active start without inverting windows. Timestamps normalize to UTC `YYYY-MM-DD HH:MM:SS` for compare with SQLite `datetime('now')`. `knowledge_as_of` reverse-applies history from the live row β€” a deleteβ†’recreate gap is not modeled as absence (triggers only fire on overwrite/delete). No agent-loop changes in this phase β€” data layer + MCP tools only. Agentic RAG and harness evolution deferred to separate issues. diff --git a/docs/adr/0005-callback-query-not-serialized-per-chat.md b/docs/adr/0005-callback-query-not-serialized-per-chat.md new file mode 100644 index 0000000..bc79849 --- /dev/null +++ b/docs/adr/0005-callback-query-not-serialized-per-chat.md @@ -0,0 +1,3 @@ +# Callback queries bypass per-chat serialization + +Teloxide `distribution_function` keyed non-command updates by chat id so messages stay ordered. Callback queries (Cancel button) shared that key, so cancel handlers queued behind the in-flight message handler stuck in `execute_command` β€” Cancel appeared dead. Decision: return `None` for `CallbackQuery` (and keep `/` commands concurrent) so cancel runs while the tool is still executing. Message ordering unchanged. diff --git a/src/command_tool.rs b/src/command_tool.rs index 4dc7134..dc7ed51 100644 --- a/src/command_tool.rs +++ b/src/command_tool.rs @@ -3,8 +3,7 @@ use async_trait::async_trait; use serde_json::{json, Value}; use std::path::PathBuf; use std::sync::Arc; -use std::time::Instant; -use tokio::io::AsyncReadExt; +use std::time::{Duration, Instant}; use tokio::process::Command as TokioCommand; use tracing::warn; @@ -13,13 +12,9 @@ use crate::llm::{FunctionDefinition, ToolDefinition}; use crate::platform::sender::PlatformSender; use crate::tool_registry::{ToolContext, ToolHandler, ToolResult, ToolUiMode}; -/// Controls how command execution messages are sent to Telegram. enum SendMode { - /// Full live output with cancel button. Verbose, - /// Cancel button only, no live edits. Message deleted on completion. Minimal, - /// No message sent. Tool notifier handles nothing (silent mode). Silent, } @@ -27,6 +22,7 @@ pub struct CommandTool { sandbox_dir: PathBuf, cancel_registry: Arc, sender: Arc, + execute_timeout_secs: u64, } impl CommandTool { @@ -34,11 +30,13 @@ impl CommandTool { sandbox_dir: PathBuf, cancel_registry: Arc, sender: Arc, + execute_timeout_secs: u64, ) -> Self { Self { sandbox_dir, cancel_registry, sender, + execute_timeout_secs, } } } @@ -89,65 +87,47 @@ impl CommandTool { let escaped_cmd = crate::utils::telegram_markdown::escape_text(command); + let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>(); + self.cancel_registry + .register(cmd_id.clone(), cancel_tx) + .await; // Verbose: cancel button + live output + final result // Minimal: cancel button (simple text) + no live output, delete on finish // Silent: no message at all (tool_notifier handles nothing) let (msg_id, send_mode) = match ctx.tool_ui_mode { ToolUiMode::Verbose => { - let status_text = - format!("πŸ’» Running: `{}`\n\n```\n⏳ Starting...\n```", escaped_cmd); + let st = format!("πŸ’» Running: `{}`\n\n```\n⏳ Starting...\n```", escaped_cmd); let id = self .sender - .show_cancel_button(&ctx.chat_id, &status_text, &cmd_id) + .show_cancel_button(&ctx.chat_id, &st, &cmd_id) .await?; (Some(id), SendMode::Verbose) } ToolUiMode::Minimal => { - let status_text = format!("⏳ Running: `{}`", escaped_cmd); + let st = format!("⏳ Running: `{}`", escaped_cmd); let id = self .sender - .show_cancel_button(&ctx.chat_id, &status_text, &cmd_id) + .show_cancel_button(&ctx.chat_id, &st, &cmd_id) .await?; (Some(id), SendMode::Minimal) } ToolUiMode::Silent => (None, SendMode::Silent), }; - let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>(); - self.cancel_registry - .register(cmd_id.clone(), cancel_tx) - .await; - let (output_tx, mut output_rx) = tokio::sync::mpsc::channel::(256); - let output_tx2 = output_tx.clone(); - let mut child_stdout = child.stdout.take(); - let mut child_stderr = child.stderr.take(); + let stdout_tx = output_tx.clone(); + let stderr_tx = output_tx.clone(); + let child_stdout = child.stdout.take(); + let child_stderr = child.stderr.take(); let stdout_handle = tokio::spawn(async move { - let mut buf = vec![0u8; 4096]; - while let Some(stream) = child_stdout.as_mut() { - match stream.read(&mut buf).await { - Ok(0) | Err(_) => break, - Ok(n) => { - let _ = output_tx - .send(String::from_utf8_lossy(&buf[..n]).to_string()) - .await; - } - } + if let Some(reader) = child_stdout { + crate::utils::process::drain_pipe(reader, stdout_tx).await; } }); - let stderr_handle = tokio::spawn(async move { - let mut buf = vec![0u8; 4096]; - while let Some(stream) = child_stderr.as_mut() { - match stream.read(&mut buf).await { - Ok(0) | Err(_) => break, - Ok(n) => { - let _ = output_tx2 - .send(String::from_utf8_lossy(&buf[..n]).to_string()) - .await; - } - } + if let Some(reader) = child_stderr { + crate::utils::process::drain_pipe(reader, stderr_tx).await; } }); @@ -156,8 +136,13 @@ impl CommandTool { let mut last_edit = Instant::now(); let mut exit_code: Option = None; let mut cancelled = false; + let mut timed_out = false; tokio::pin!(cancel_rx); + let timeout_secs = self.execute_timeout_secs; + let timeout_fut = crate::utils::process::optional_timeout(timeout_secs); + tokio::pin!(timeout_fut); + loop { tokio::select! { Some(chunk) = output_rx.recv() => { @@ -165,7 +150,7 @@ impl CommandTool { if output_buffer.chars().count() > MAX_BUFFER_CHARS { output_buffer = crate::utils::strings::truncate_tail(&output_buffer, MAX_BUFFER_CHARS); } - if matches!(send_mode, SendMode::Verbose) && last_edit.elapsed() >= std::time::Duration::from_millis(500) { + if matches!(send_mode, SendMode::Verbose) && last_edit.elapsed() >= Duration::from_millis(500) { let capped = crate::utils::strings::truncate_tail(&output_buffer, 3500); let text = format!("πŸ’» Running: `{}`\n\n```\n{}\n```", escaped_cmd, capped); if let Some(mid) = &msg_id { @@ -182,21 +167,25 @@ impl CommandTool { } _ = &mut cancel_rx => { cancelled = true; - #[cfg(unix)] - if let Some(pid) = child.id() { - let _ = nix::sys::signal::killpg( - nix::unistd::Pid::from_raw(pid as i32), - nix::sys::signal::Signal::SIGKILL, - ); - } - let _ = child.kill().await; - let _ = child.wait().await; + crate::utils::process::kill_child(&mut child).await; + break; + } + _ = &mut timeout_fut => { + timed_out = true; + crate::utils::process::kill_child(&mut child).await; break; } } } - let _ = tokio::join!(stdout_handle, stderr_handle); + // Post-exit drain with timeout guard + let drain = tokio::time::timeout(Duration::from_secs(5), async move { + tokio::join!(stdout_handle, stderr_handle) + }); + if drain.await.is_err() { + warn!("command_tool: drain timed out after child exit"); + } + while let Ok(chunk) = output_rx.try_recv() { output_buffer.push_str(&chunk); } @@ -217,26 +206,38 @@ impl CommandTool { } } - let result = if cancelled { + let result = if cancelled || timed_out { + let label = if timed_out { + format!("Timed out after {}s", timeout_secs) + } else { + "Cancelled".to_string() + }; if let Some(mid) = &msg_id { match send_mode { SendMode::Verbose => { let body = format_body(&output_buffer, ""); let text = match body { - None => format!("❌ Cancelled: `{}`", escaped_cmd), - Some(b) => format!("❌ Cancelled: `{}`\n\n{}", escaped_cmd, b), + None => format!("❌ {}: `{}`", label, escaped_cmd), + Some(b) => format!("❌ {}: `{}`\n\n{}", label, escaped_cmd, b), }; let _ = self.sender.edit_message(&ctx.chat_id, mid, &text).await; } SendMode::Minimal => { - // Delete the minimal message let _ = self.sender.delete_message(&ctx.chat_id, mid).await; } - // Silent mode sends no message; nothing to clean up. SendMode::Silent => {} } } - "⚠️ User cancelled the command".to_string() + let mut msg = if timed_out { + format!("⚠️ Command timed out after {}s", timeout_secs) + } else { + "⚠️ User cancelled the command".to_string() + }; + if !output_buffer.is_empty() { + let capped = crate::utils::strings::truncate_tail(&output_buffer, 3500); + msg.push_str(&format!("\n\nPartial output:\n```\n{}\n```", capped)); + } + msg } else if let Some(code) = exit_code { if let Some(mid) = &msg_id { match send_mode { @@ -257,10 +258,8 @@ impl CommandTool { let _ = self.sender.edit_message(&ctx.chat_id, mid, &text).await; } SendMode::Minimal => { - // Delete the minimal message let _ = self.sender.delete_message(&ctx.chat_id, mid).await; } - // Silent mode sends no message; nothing to clean up. SendMode::Silent => {} } } diff --git a/src/config.rs b/src/config.rs index d152a01..e5496ec 100644 --- a/src/config.rs +++ b/src/config.rs @@ -184,10 +184,26 @@ pub struct OcrConfig { pub model_dir: std::path::PathBuf, } -#[derive(Debug, Deserialize, Clone, Default)] +#[derive(Debug, Deserialize, Clone)] pub struct SandboxConfig { #[serde(default)] pub allowed_directory: PathBuf, + /// Wall-clock limit for `execute_command`. 0 = no timeout. + #[serde(default = "default_execute_timeout_secs")] + pub execute_timeout_secs: u64, +} + +impl Default for SandboxConfig { + fn default() -> Self { + Self { + allowed_directory: PathBuf::new(), + execute_timeout_secs: default_execute_timeout_secs(), + } + } +} + +fn default_execute_timeout_secs() -> u64 { + 90 } #[derive(Debug, Deserialize, Clone)] diff --git a/src/main.rs b/src/main.rs index 0941b30..81c9596 100644 --- a/src/main.rs +++ b/src/main.rs @@ -241,6 +241,7 @@ async fn main() -> Result<()> { config.sandbox.allowed_directory.clone(), cancel_registry.clone(), sender.clone(), + config.sandbox.execute_timeout_secs, ))); // Arc::new_cyclic so Agent can store Weak for job closure captures (breaks Arc cycle) @@ -423,6 +424,7 @@ async fn main() -> Result<()> { sup_registry.register(std::sync::Arc::new( rustfox::supervisor::backend::shell::ShellBackend::new( config.sandbox.allowed_directory.clone(), + config.sandbox.execute_timeout_secs, ), )); diff --git a/src/memory/knowledge.rs b/src/memory/knowledge.rs index 7e825cf..3ba2adb 100644 --- a/src/memory/knowledge.rs +++ b/src/memory/knowledge.rs @@ -15,6 +15,54 @@ pub struct KnowledgeEntry { pub source: Option, } +/// One archived knowledge change (trigger-written). +#[derive(Debug, Clone)] +pub struct KnowledgeVersion { + pub id: String, + pub category: String, + pub key: String, + pub old_value: Option, + pub new_value: Option, + pub source: Option, + pub change_type: String, + pub changed_at: String, +} + +/// Time-bounded triple. +#[derive(Debug, Clone)] +pub struct Fact { + pub id: String, + pub entity: String, + pub relation: String, + pub value: String, + pub valid_from: String, + pub valid_to: Option, + pub source: Option, + pub confidence: f64, +} + +impl std::fmt::Display for Fact { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let until = self.valid_to.as_deref().unwrap_or("…"); + write!( + f, + "{} β€”{}β†’ {} [{}..{}] conf={}", + self.entity, self.relation, self.value, self.valid_from, until, self.confidence + ) + } +} + +/// Input for [`MemoryStore::add_fact`]. +#[derive(Debug, Clone)] +pub struct FactToAdd { + pub entity: String, + pub relation: String, + pub value: String, + pub valid_from: String, + pub source: Option, + pub confidence: Option, +} + impl MemoryStore { /// Store or update a knowledge entry with vector embedding pub async fn remember( @@ -206,6 +254,275 @@ impl MemoryStore { )?; Ok(rows > 0) } + + /// Full version timeline for a (category, key) pair, oldest first. + pub async fn knowledge_timeline( + &self, + category: &str, + key: &str, + ) -> Result> { + let conn = self.conn.lock().await; + let mut stmt = conn.prepare( + "SELECT id, category, key, old_value, new_value, source, change_type, changed_at + FROM knowledge_history + WHERE category = ?1 AND key = ?2 + ORDER BY changed_at ASC, rowid ASC", + )?; + let rows = stmt + .query_map(rusqlite::params![category, key], parse_knowledge_version)? + .collect::, _>>() + .context("Failed to load knowledge timeline")?; + Ok(rows) + } + + /// Reconstruct knowledge value as of a SQLite datetime string. + pub async fn knowledge_as_of( + &self, + category: &str, + key: &str, + as_of: &str, + ) -> Result> { + let as_of = normalize_as_of(as_of); + let conn = self.conn.lock().await; + + let current: Option<(String, String)> = conn + .query_row( + "SELECT value, created_at FROM knowledge WHERE category = ?1 AND key = ?2", + rusqlite::params![category, key], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .ok(); + + let mut stmt = conn.prepare( + "SELECT id, category, key, old_value, new_value, source, change_type, changed_at + FROM knowledge_history + WHERE category = ?1 AND key = ?2 + ORDER BY changed_at DESC, rowid DESC", + )?; + let history: Vec = stmt + .query_map(rusqlite::params![category, key], parse_knowledge_version)? + .collect::, _>>() + .context("Failed to load knowledge history for as_of")?; + + // Reverse-apply changes newer than as_of onto the live value. + let mut val = current.as_ref().map(|(v, _)| v.clone()); + for h in &history { + if normalize_from(&h.changed_at).as_str() <= as_of.as_str() { + break; + } + val = h.old_value.clone(); + } + + // Before first create and no residual history β†’ absent. + if let Some((_, ref created_at)) = current { + let created = normalize_from(created_at); + if as_of.as_str() < created.as_str() && history.is_empty() { + return Ok(None); + } + if as_of.as_str() < created.as_str() { + let earliest = history.iter().map(|h| normalize_from(&h.changed_at)).min(); + if earliest + .as_ref() + .is_none_or(|e| as_of.as_str() < e.as_str()) + { + return Ok(None); + } + } + } + + Ok(val) + } + + /// Insert fact; skip if identical active exists. Auto-closes prior active for pair. + /// Backdated inserts (valid_from before active start) land as closed history rows. + pub async fn add_fact(&self, fact: FactToAdd) -> Result { + let conn = self.conn.lock().await; + let confidence = fact.confidence.unwrap_or(1.0); + let valid_from = normalize_from(&fact.valid_from); + + let existing: Option = conn + .query_row( + "SELECT id FROM facts + WHERE entity = ?1 AND relation = ?2 AND value = ?3 AND valid_to IS NULL", + rusqlite::params![&fact.entity, &fact.relation, &fact.value], + |row| row.get(0), + ) + .ok(); + if let Some(id) = existing { + return Ok(id); + } + + let active_from: Option = conn + .query_row( + "SELECT valid_from FROM facts + WHERE entity = ?1 AND relation = ?2 AND valid_to IS NULL", + rusqlite::params![&fact.entity, &fact.relation], + |row| row.get(0), + ) + .ok(); + + let id = Uuid::new_v4().to_string(); + + if let Some(ref active_vf) = active_from { + let active_vf_n = normalize_from(active_vf); + if valid_from.as_str() < active_vf_n.as_str() { + // Historical backfill: closed against active start; leave active alone. + conn.execute( + "INSERT INTO facts + (id, entity, relation, value, valid_from, valid_to, source, confidence) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + rusqlite::params![ + &id, + &fact.entity, + &fact.relation, + &fact.value, + &valid_from, + &active_vf_n, + &fact.source, + confidence + ], + ) + .context("Failed to insert historical fact")?; + return Ok(id); + } + + conn.execute( + "UPDATE facts SET valid_to = ?1 + WHERE entity = ?2 AND relation = ?3 AND valid_to IS NULL", + rusqlite::params![&valid_from, &fact.entity, &fact.relation], + ) + .context("Failed to close prior active fact")?; + } + + conn.execute( + "INSERT INTO facts (id, entity, relation, value, valid_from, source, confidence) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + &id, + &fact.entity, + &fact.relation, + &fact.value, + &valid_from, + &fact.source, + confidence + ], + ) + .context("Failed to insert fact")?; + Ok(id) + } + + /// End currently-active fact for (entity, relation). + pub async fn close_fact(&self, entity: &str, relation: &str, valid_to: &str) -> Result { + let valid_to = normalize_from(valid_to); + let conn = self.conn.lock().await; + let rows = conn + .execute( + "UPDATE facts SET valid_to = ?1 + WHERE entity = ?2 AND relation = ?3 AND valid_to IS NULL + AND valid_from <= ?1", + rusqlite::params![&valid_to, entity, relation], + ) + .context("Failed to close fact")?; + Ok(rows > 0) + } + + /// Facts for entity; `as_of = None` β†’ currently active only. + pub async fn query_facts(&self, entity: &str, as_of: Option<&str>) -> Result> { + let conn = self.conn.lock().await; + if let Some(as_of) = as_of { + let as_of = normalize_as_of(as_of); + let mut stmt = conn.prepare( + "SELECT id, entity, relation, value, valid_from, valid_to, source, confidence + FROM facts + WHERE entity = ?1 + AND valid_from <= ?2 + AND (valid_to IS NULL OR valid_to > ?2) + ORDER BY relation, valid_from", + )?; + let facts = stmt + .query_map(rusqlite::params![entity, as_of], parse_fact)? + .collect::, _>>() + .context("Failed to query facts as_of")?; + Ok(facts) + } else { + let mut stmt = conn.prepare( + "SELECT id, entity, relation, value, valid_from, valid_to, source, confidence + FROM facts + WHERE entity = ?1 AND valid_to IS NULL + ORDER BY relation, valid_from", + )?; + let facts = stmt + .query_map(rusqlite::params![entity], parse_fact)? + .collect::, _>>() + .context("Failed to query active facts")?; + Ok(facts) + } + } + + /// Full timeline for one relation, oldest first. + pub async fn fact_timeline(&self, entity: &str, relation: &str) -> Result> { + let conn = self.conn.lock().await; + let mut stmt = conn.prepare( + "SELECT id, entity, relation, value, valid_from, valid_to, source, confidence + FROM facts + WHERE entity = ?1 AND relation = ?2 + ORDER BY valid_from ASC, created_at ASC", + )?; + let facts = stmt + .query_map(rusqlite::params![entity, relation], parse_fact)? + .collect::, _>>() + .context("Failed to load fact timeline")?; + Ok(facts) + } +} + +/// Canonicalize to UTC `YYYY-MM-DD HH:MM:SS` for compare with SQLite `datetime('now')` (UTC). +fn normalize_from(ts: &str) -> String { + let ts = ts.trim(); + if is_date_only(ts) { + return format!("{ts} 00:00:00"); + } + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts) { + return dt + .with_timezone(&chrono::Utc) + .format("%Y-%m-%d %H:%M:%S") + .to_string(); + } + // Handle Z suffix (e.g. "2025-06-01T12:00Z") β€” strip Z, optionally append :00 seconds. + if ts.ends_with('Z') || ts.ends_with('z') { + let without_z = ts.trim_end_matches(['Z', 'z']); + let with_secs = format!("{}:00", without_z); + if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(&with_secs, "%Y-%m-%dT%H:%M:%S") { + return naive.format("%Y-%m-%d %H:%M:%S").to_string(); + } + if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(without_z, "%Y-%m-%dT%H:%M:%S") { + return naive.format("%Y-%m-%d %H:%M:%S").to_string(); + } + } + // ISO with T, no offset β†’ treat as already-UTC wall clock. + if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%dT%H:%M:%S") { + return naive.format("%Y-%m-%d %H:%M:%S").to_string(); + } + if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%d %H:%M:%S") { + return naive.format("%Y-%m-%d %H:%M:%S").to_string(); + } + ts.replace('T', " ") +} + +/// Date-only β†’ end of UTC day so "as of that day" includes the whole day. +fn normalize_as_of(ts: &str) -> String { + let ts = ts.trim(); + if is_date_only(ts) { + return format!("{ts} 23:59:59"); + } + normalize_from(ts) +} + +fn is_date_only(ts: &str) -> bool { + ts.len() == 10 + && ts.as_bytes().get(4) == Some(&b'-') + && ts.as_bytes().get(7) == Some(&b'-') + && ts.bytes().all(|b| b.is_ascii_digit() || b == b'-') } fn parse_knowledge_row(row: &rusqlite::Row) -> rusqlite::Result { @@ -217,3 +534,249 @@ fn parse_knowledge_row(row: &rusqlite::Row) -> rusqlite::Result source: row.get(4)?, }) } + +fn parse_knowledge_version(row: &rusqlite::Row) -> rusqlite::Result { + Ok(KnowledgeVersion { + id: row.get(0)?, + category: row.get(1)?, + key: row.get(2)?, + old_value: row.get(3)?, + new_value: row.get(4)?, + source: row.get(5)?, + change_type: row.get(6)?, + changed_at: row.get(7)?, + }) +} + +fn parse_fact(row: &rusqlite::Row) -> rusqlite::Result { + Ok(Fact { + id: row.get(0)?, + entity: row.get(1)?, + relation: row.get(2)?, + value: row.get(3)?, + valid_from: row.get(4)?, + valid_to: row.get(5)?, + source: row.get(6)?, + confidence: row.get(7)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::memory::MemoryStore; + + #[tokio::test] + async fn test_knowledge_history_archives_on_update() { + let store = MemoryStore::open_in_memory().unwrap(); + store.remember("pref", "brand", "Nike", None).await.unwrap(); + store + .remember("pref", "brand", "Adidas", None) + .await + .unwrap(); + let tl = store.knowledge_timeline("pref", "brand").await.unwrap(); + assert_eq!(tl.len(), 1); + assert_eq!(tl[0].old_value.as_deref(), Some("Nike")); + assert_eq!(tl[0].new_value.as_deref(), Some("Adidas")); + assert_eq!(tl[0].change_type, "update"); + } + + #[tokio::test] + async fn test_knowledge_history_archives_on_delete() { + let store = MemoryStore::open_in_memory().unwrap(); + store.remember("pref", "x", "1", None).await.unwrap(); + assert!(store.forget("pref", "x").await.unwrap()); + let tl = store.knowledge_timeline("pref", "x").await.unwrap(); + assert_eq!(tl.len(), 1); + assert_eq!(tl[0].change_type, "delete"); + assert_eq!(tl[0].old_value.as_deref(), Some("1")); + assert!(tl[0].new_value.is_none()); + } + + #[tokio::test] + async fn test_knowledge_as_of_point_in_time() { + let store = MemoryStore::open_in_memory().unwrap(); + store.remember("pref", "brand", "Nike", None).await.unwrap(); + // before any write + assert!(store + .knowledge_as_of("pref", "brand", "2000-01-01") + .await + .unwrap() + .is_none()); + // after write (current) + assert_eq!( + store + .knowledge_as_of("pref", "brand", "9999-01-01") + .await + .unwrap() + .as_deref(), + Some("Nike") + ); + store + .remember("pref", "brand", "Adidas", None) + .await + .unwrap(); + let tl = store.knowledge_timeline("pref", "brand").await.unwrap(); + let changed = &tl[0].changed_at; + // just before the change β†’ Nike (changed_at is second-resolution; use reverse path) + // After full reverse of the Adidas update, old is Nike. + // as_of equal to changed_at keeps the change (changed_at <= as_of means applied). + assert_eq!( + store + .knowledge_as_of("pref", "brand", changed) + .await + .unwrap() + .as_deref(), + Some("Adidas") + ); + } + + #[tokio::test] + async fn test_add_and_query_fact() { + let store = MemoryStore::open_in_memory().unwrap(); + let id = store + .add_fact(FactToAdd { + entity: "Kan".into(), + relation: "prefers".into(), + value: "Nike".into(), + valid_from: "2024-09-01".into(), + source: None, + confidence: Some(0.9), + }) + .await + .unwrap(); + assert!(!id.is_empty()); + let facts = store.query_facts("Kan", None).await.unwrap(); + assert_eq!(facts.len(), 1); + assert_eq!(facts[0].value, "Nike"); + } + + #[tokio::test] + async fn test_idempotent_add_and_auto_close() { + let store = MemoryStore::open_in_memory().unwrap(); + let a = store + .add_fact(FactToAdd { + entity: "Kan".into(), + relation: "prefers".into(), + value: "Nike".into(), + valid_from: "2024-01-01".into(), + source: None, + confidence: None, + }) + .await + .unwrap(); + let a2 = store + .add_fact(FactToAdd { + entity: "Kan".into(), + relation: "prefers".into(), + value: "Nike".into(), + valid_from: "2024-06-01".into(), + source: None, + confidence: None, + }) + .await + .unwrap(); + assert_eq!(a, a2); + let _ = store + .add_fact(FactToAdd { + entity: "Kan".into(), + relation: "prefers".into(), + value: "Adidas".into(), + valid_from: "2026-03-01".into(), + source: None, + confidence: None, + }) + .await + .unwrap(); + let active = store.query_facts("Kan", None).await.unwrap(); + assert_eq!(active.len(), 1); + assert_eq!(active[0].value, "Adidas"); + let past = store.query_facts("Kan", Some("2025-06-01")).await.unwrap(); + assert_eq!(past.len(), 1); + assert_eq!(past[0].value, "Nike"); + let tl = store.fact_timeline("Kan", "prefers").await.unwrap(); + assert_eq!(tl.len(), 2); + } + + #[tokio::test] + async fn test_close_fact() { + let store = MemoryStore::open_in_memory().unwrap(); + store + .add_fact(FactToAdd { + entity: "Kan".into(), + relation: "lives_in".into(), + value: "HK".into(), + valid_from: "2020-01-01".into(), + source: None, + confidence: None, + }) + .await + .unwrap(); + assert!(store + .close_fact("Kan", "lives_in", "2026-01-01") + .await + .unwrap()); + assert!(store.query_facts("Kan", None).await.unwrap().is_empty()); + let past = store.query_facts("Kan", Some("2025-01-01")).await.unwrap(); + assert_eq!(past[0].value, "HK"); + } + + #[tokio::test] + async fn test_add_fact_backfill_no_invert() { + let store = MemoryStore::open_in_memory().unwrap(); + store + .add_fact(FactToAdd { + entity: "Kan".into(), + relation: "prefers".into(), + value: "Adidas".into(), + valid_from: "2026-01-01".into(), + source: None, + confidence: None, + }) + .await + .unwrap(); + store + .add_fact(FactToAdd { + entity: "Kan".into(), + relation: "prefers".into(), + value: "Nike".into(), + valid_from: "2024-01-01".into(), + source: None, + confidence: None, + }) + .await + .unwrap(); + let active = store.query_facts("Kan", None).await.unwrap(); + assert_eq!(active.len(), 1); + assert_eq!(active[0].value, "Adidas"); + let past = store.query_facts("Kan", Some("2025-06-01")).await.unwrap(); + assert_eq!(past.len(), 1); + assert_eq!(past[0].value, "Nike"); + assert!(past[0].valid_to.is_some()); + } + + #[test] + fn test_date_only_normalize() { + assert_eq!(normalize_as_of("2025-06-01"), "2025-06-01 23:59:59"); + assert_eq!(normalize_from("2025-06-01"), "2025-06-01 00:00:00"); + assert_eq!(normalize_from("2025-06-01 12:00:00"), "2025-06-01 12:00:00"); + assert_eq!( + normalize_from("2025-06-01T12:00:00Z"), + "2025-06-01 12:00:00" + ); + // Offset converted to UTC (+08 β†’ 04:00Z). + assert_eq!( + normalize_from("2025-06-01T12:00:00+08:00"), + "2025-06-01 04:00:00" + ); + } + + #[test] + fn test_normalize_from_z_suffix() { + assert_eq!(normalize_from("2025-06-01T12:00Z"), "2025-06-01 12:00:00"); + assert_eq!( + normalize_from("2025-06-01T12:00:00Z"), + "2025-06-01 12:00:00" + ); + } +} diff --git a/src/memory/mod.rs b/src/memory/mod.rs index 5257775..1d6a8cd 100644 --- a/src/memory/mod.rs +++ b/src/memory/mod.rs @@ -309,9 +309,77 @@ impl MemoryStore { FOREIGN KEY (task_id) REFERENCES sup_tasks(id) ); CREATE INDEX IF NOT EXISTS idx_sup_artifacts_task ON sup_artifacts(task_id, kind); + + -- Knowledge version history (auto-filled by triggers) + CREATE TABLE IF NOT EXISTS knowledge_history ( + id TEXT PRIMARY KEY, + category TEXT NOT NULL, + key TEXT NOT NULL, + old_value TEXT, + new_value TEXT, + source TEXT, + change_type TEXT NOT NULL CHECK(change_type IN ('update','delete')), + changed_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_knowledge_history_cat_key + ON knowledge_history(category, key, changed_at); + + CREATE TRIGGER IF NOT EXISTS knowledge_history_update + AFTER UPDATE ON knowledge + WHEN OLD.value IS NOT NULL AND OLD.value != NEW.value + BEGIN + INSERT INTO knowledge_history + (id, category, key, old_value, new_value, source, change_type) + VALUES ( + printf('hist_%s', lower(hex(randomblob(16)))), + OLD.category, OLD.key, OLD.value, NEW.value, NEW.source, 'update' + ); + END; + + CREATE TRIGGER IF NOT EXISTS knowledge_history_delete + AFTER DELETE ON knowledge + BEGIN + INSERT INTO knowledge_history + (id, category, key, old_value, new_value, source, change_type) + VALUES ( + printf('hist_%s', lower(hex(randomblob(16)))), + OLD.category, OLD.key, OLD.value, NULL, OLD.source, 'delete' + ); + END; + + -- Temporal facts (one active value per entity+relation) + CREATE TABLE IF NOT EXISTS facts ( + id TEXT PRIMARY KEY, + entity TEXT NOT NULL, + relation TEXT NOT NULL, + value TEXT NOT NULL, + valid_from TEXT NOT NULL, + valid_to TEXT, + source TEXT, + confidence REAL NOT NULL DEFAULT 1.0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_facts_entity_relation + ON facts(entity, relation, valid_from); ", )?; + // Close duplicate active facts (keep max rowid per pair) before UNIQUE index. + // rowid tiebreak handles same-second created_at collisions. + conn.execute_batch( + " + UPDATE facts SET valid_to = COALESCE(valid_to, valid_from) + WHERE valid_to IS NULL + AND rowid NOT IN ( + SELECT MAX(rowid) FROM facts WHERE valid_to IS NULL + GROUP BY entity, relation + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_facts_one_active + ON facts(entity, relation) WHERE valid_to IS NULL; + ", + ) + .context("facts one-active index migration")?; + // Migration: add is_summarized column (safe no-op if column already exists) conn.execute_batch("ALTER TABLE messages ADD COLUMN is_summarized BOOLEAN DEFAULT 0;") .ok(); // ok() because ALTER TABLE fails if column already exists β€” that's intentional diff --git a/src/memory_tools.rs b/src/memory_tools.rs index af0b30d..55b7572 100644 --- a/src/memory_tools.rs +++ b/src/memory_tools.rs @@ -3,6 +3,7 @@ use async_trait::async_trait; use serde_json::{json, Value}; use crate::llm::{FunctionDefinition, ToolDefinition}; +use crate::memory::knowledge::FactToAdd; use crate::memory::MemoryStore; use crate::tool_registry::{ToolContext, ToolHandler, ToolResult}; @@ -66,6 +67,73 @@ impl ToolHandler for MemoryTools { }), }, }, + ToolDefinition { + tool_type: "function".to_string(), + function: FunctionDefinition { + name: "add_fact".to_string(), + description: "Record a temporal fact (entity, relation, value) with validity start. Replaces any prior active value for the same entity+relation.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "entity": { "type": "string", "description": "Subject entity (e.g. 'Kan')" }, + "relation": { "type": "string", "description": "Relation (e.g. 'prefers')" }, + "value": { "type": "string", "description": "Object value (e.g. 'Nike')" }, + "valid_from": { "type": "string", "description": "ISO date when fact became true (e.g. '2024-09-01')" }, + "source": { "type": "string", "description": "Optional provenance" }, + "confidence": { "type": "number", "description": "0.0-1.0, default 1.0" } + }, + "required": ["entity", "relation", "value", "valid_from"] + }), + }, + }, + ToolDefinition { + tool_type: "function".to_string(), + function: FunctionDefinition { + name: "query_facts".to_string(), + description: "Get facts for an entity. Omit as_of for currently active facts; pass a date for a point-in-time snapshot.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "entity": { "type": "string" }, + "as_of": { "type": "string", "description": "Optional ISO date snapshot" } + }, + "required": ["entity"] + }), + }, + }, + ToolDefinition { + tool_type: "function".to_string(), + function: FunctionDefinition { + name: "close_fact".to_string(), + description: "End the currently-active fact for an entity+relation at valid_to.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "entity": { "type": "string" }, + "relation": { "type": "string" }, + "valid_to": { "type": "string", "description": "ISO date when fact stopped being true" } + }, + "required": ["entity", "relation", "valid_to"] + }), + }, + }, + ToolDefinition { + tool_type: "function".to_string(), + function: FunctionDefinition { + name: "fact_history".to_string(), + description: "Version timeline for knowledge (category+key) or facts (entity+relation). With category+key+as_of, returns knowledge value at that time.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "category": { "type": "string", "description": "Knowledge category" }, + "key": { "type": "string", "description": "Knowledge key" }, + "entity": { "type": "string", "description": "Fact entity" }, + "relation": { "type": "string", "description": "Fact relation" }, + "as_of": { "type": "string", "description": "Optional ISO date; with category+key returns knowledge_as_of" } + } + }), + }, + }, ] } @@ -123,6 +191,98 @@ impl ToolHandler for MemoryTools { Ok(results.join("\n\n")) } } + "add_fact" => { + let entity = args["entity"].as_str().context("Missing entity")?; + let relation = args["relation"].as_str().context("Missing relation")?; + let value = args["value"].as_str().context("Missing value")?; + let valid_from = args["valid_from"].as_str().context("Missing valid_from")?; + let source = args["source"].as_str().map(|s| s.to_string()); + let confidence = args["confidence"].as_f64(); + match self + .memory + .add_fact(FactToAdd { + entity: entity.into(), + relation: relation.into(), + value: value.into(), + valid_from: valid_from.into(), + source, + confidence, + }) + .await + { + Ok(id) => Ok(format!( + "Fact {id}: {entity} β€”{relation}β†’ {value} (from {valid_from})" + )), + Err(e) => Ok(format!("Failed to add fact: {e}")), + } + } + "query_facts" => { + let entity = args["entity"].as_str().context("Missing entity")?; + let as_of = args["as_of"].as_str(); + match self.memory.query_facts(entity, as_of).await { + Ok(facts) if facts.is_empty() => Ok("No facts found.".into()), + Ok(facts) => Ok(facts + .iter() + .map(|f| f.to_string()) + .collect::>() + .join("\n")), + Err(e) => Ok(format!("Failed to query facts: {e}")), + } + } + "close_fact" => { + let entity = args["entity"].as_str().context("Missing entity")?; + let relation = args["relation"].as_str().context("Missing relation")?; + let valid_to = args["valid_to"].as_str().context("Missing valid_to")?; + match self.memory.close_fact(entity, relation, valid_to).await { + Ok(true) => Ok(format!("Closed {entity}.{relation} at {valid_to}")), + Ok(false) => Ok("No active fact to close.".into()), + Err(e) => Ok(format!("Failed to close fact: {e}")), + } + } + "fact_history" => { + let category = args["category"].as_str(); + let key = args["key"].as_str(); + let entity = args["entity"].as_str(); + let relation = args["relation"].as_str(); + let as_of = args["as_of"].as_str(); + if let (Some(cat), Some(k), Some(as_of)) = (category, key, as_of) { + match self.memory.knowledge_as_of(cat, k, as_of).await { + Ok(Some(v)) => Ok(format!("[{cat}/{k}] as of {as_of}: {v}")), + Ok(None) => Ok(format!("No value for [{cat}/{k}] as of {as_of}")), + Err(e) => Ok(format!("Failed knowledge_as_of: {e}")), + } + } else if let (Some(cat), Some(k)) = (category, key) { + match self.memory.knowledge_timeline(cat, k).await { + Ok(versions) if versions.is_empty() => Ok("No knowledge history.".into()), + Ok(versions) => Ok(versions + .iter() + .map(|v| { + format!( + "[{}] {} β†’ {} ({})", + v.changed_at, + v.old_value.as_deref().unwrap_or("βˆ…"), + v.new_value.as_deref().unwrap_or("βˆ…"), + v.change_type + ) + }) + .collect::>() + .join("\n")), + Err(e) => Ok(format!("Failed knowledge history: {e}")), + } + } else if let (Some(ent), Some(rel)) = (entity, relation) { + match self.memory.fact_timeline(ent, rel).await { + Ok(facts) if facts.is_empty() => Ok("No fact history.".into()), + Ok(facts) => Ok(facts + .iter() + .map(|f| f.to_string()) + .collect::>() + .join("\n")), + Err(e) => Ok(format!("Failed fact history: {e}")), + } + } else { + Ok("Provide category+key (knowledge) or entity+relation (facts).".into()) + } + } _ => anyhow::bail!("MemoryTools: unknown tool {name}"), } } diff --git a/src/platform/telegram.rs b/src/platform/telegram.rs index 99868a1..1acf512 100644 --- a/src/platform/telegram.rs +++ b/src/platform/telegram.rs @@ -290,6 +290,11 @@ pub async fn run( // Commands (like /btw) bypass per-chat serialization for true concurrency. // Regular messages keep per-chat ordering to avoid race conditions. .distribution_function(|upd: &Update| { + // Callbacks (Cancel button) must run concurrent with the in-flight + // message handler β€” chat-keying them queues cancel behind execute_command. + if matches!(upd.kind, UpdateKind::CallbackQuery(_)) { + return None; + } let is_cmd = match &upd.kind { UpdateKind::Message(m) | UpdateKind::EditedMessage(m) diff --git a/src/supervisor/backend/shell.rs b/src/supervisor/backend/shell.rs index 883adcb..e06f7ee 100644 --- a/src/supervisor/backend/shell.rs +++ b/src/supervisor/backend/shell.rs @@ -1,17 +1,37 @@ use anyhow::Result; use std::path::PathBuf; +use std::time::Duration; +use tokio::io::AsyncRead; +use tokio::io::AsyncReadExt; use tokio::process::Command; +use tokio::time::timeout; +use tracing::warn; use crate::supervisor::backend::{Backend, BackendCapabilities, RunContext}; use crate::supervisor::job::{Evidence, Job, JobOutput, JobStatus, JobType}; +use crate::utils::process::{kill_child, optional_timeout}; pub struct ShellBackend { sandbox: PathBuf, + /// From `sandbox.execute_timeout_secs`. 0 = no sandbox cap (job.timeout_secs only). + execute_timeout_secs: u64, } impl ShellBackend { - pub fn new(sandbox: PathBuf) -> Self { - Self { sandbox } + pub fn new(sandbox: PathBuf, execute_timeout_secs: u64) -> Self { + Self { + sandbox, + execute_timeout_secs, + } + } + + /// Tighter of job deadline and sandbox wall-clock. 0 sandbox = job only. + fn effective_timeout_secs(&self, job_timeout_secs: u64) -> u64 { + match self.execute_timeout_secs { + 0 => job_timeout_secs, + n if job_timeout_secs == 0 => n, + n => n.min(job_timeout_secs), + } } // TODO(security, M2.5): naive validation β€” only catches obvious `cd /…`, @@ -31,6 +51,40 @@ impl ShellBackend { } } +/// Drain bytes from an async reader into a `String` until EOF. +/// +/// Used for capturing `ChildStdout` / `ChildStderr` in the shell backend. +async fn drain(mut reader: R) -> String +where + R: AsyncRead + Unpin, +{ + let mut out = String::new(); + let mut buf = vec![0u8; 8192]; + loop { + match reader.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => out.push_str(&String::from_utf8_lossy(&buf[..n])), + } + } + out +} + +/// Await a spawned drain handle, but cap the wait so a daemon that inherits +/// the pipe cannot hang the shell backend indefinitely. +async fn drain_with_timeout(handle: tokio::task::JoinHandle) -> String { + match timeout(Duration::from_secs(5), handle).await { + Ok(Ok(s)) => s, + Ok(Err(join_err)) => { + warn!("drain join error: {join_err}"); + String::new() + } + Err(elapsed) => { + warn!("drain timed out after {elapsed:?}, pipe may be held by daemon"); + String::new() + } + } +} + #[async_trait::async_trait] impl Backend for ShellBackend { fn name(&self) -> &str { @@ -49,25 +103,73 @@ impl Backend for ShellBackend { let cmd = job.prompt.clone().unwrap_or_else(|| job.goal.clone()); if !self.validate(&cmd) { job.status = JobStatus::Failed; - return Ok(JobOutput { - status: JobStatus::Failed, - summary: String::new(), - evidence: vec![], - errors: vec!["sandbox-violation: cd outside sandbox".into()], - changed_files: vec![], - next_step: None, - }); + return Ok(JobOutput::failed(vec![ + "sandbox-violation: cd outside sandbox".into(), + ])); } - let output = Command::new("sh") + + let timeout_secs = self.effective_timeout_secs(job.timeout_secs); + let mut command = Command::new("sh"); + command .arg("-c") .arg(&cmd) .current_dir(&self.sandbox) - .output() - .await?; - let exit = output.status.code().unwrap_or(-1); - let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); - let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); - let status = if output.status.success() { + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + #[cfg(unix)] + command.process_group(0); + + let mut child = match command.spawn() { + Ok(c) => c, + Err(e) => { + job.status = JobStatus::Failed; + return Ok(JobOutput::failed(vec![format!("command failed: {e}")])); + } + }; + + // Drain pipes concurrently so a large writer cannot block wait(). + let stdout_handle = child.stdout.take().map(|s| tokio::spawn(drain(s))); + let stderr_handle = child.stderr.take().map(|s| tokio::spawn(drain(s))); + + let timeout_fut = optional_timeout(timeout_secs); + tokio::pin!(timeout_fut); + + let timed_out; + let exit_code; + tokio::select! { + status = child.wait() => { + timed_out = false; + exit_code = status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1); + } + _ = &mut timeout_fut => { + timed_out = true; + exit_code = -1; + kill_child(&mut child).await; + } + } + + // Protect drain from daemon processes that hold pipe write end open. + let stdout = match stdout_handle { + Some(h) => drain_with_timeout(h).await, + None => String::new(), + }; + let stderr = match stderr_handle { + Some(h) => drain_with_timeout(h).await, + None => String::new(), + }; + + if timed_out { + job.status = JobStatus::Failed; + let mut errors = vec![format!("timed out after {timeout_secs}s")]; + if !stderr.is_empty() { + errors.push(stderr); + } + let mut out = JobOutput::failed(errors); + out.summary = stdout.trim().to_string(); + return Ok(out); + } + + let status = if exit_code == 0 { JobStatus::Succeeded } else { JobStatus::Failed @@ -76,7 +178,7 @@ impl Backend for ShellBackend { Ok(JobOutput { status, summary: stdout.trim().to_string(), - evidence: vec![Evidence::ExitCode { code: exit }], + evidence: vec![Evidence::ExitCode { code: exit_code }], errors: if stderr.is_empty() { vec![] } else { @@ -91,11 +193,12 @@ impl Backend for ShellBackend { #[cfg(test)] mod tests { use super::*; + use std::time::Duration; #[tokio::test] async fn shell_backend_runs_echo_in_sandbox() { let dir = tempfile::tempdir().unwrap(); - let b = ShellBackend::new(dir.path().into()); + let b = ShellBackend::new(dir.path().into(), 90); let mut job = crate::supervisor::job::Job::new( "t", crate::supervisor::job::JobType::ShellJob, @@ -118,7 +221,7 @@ mod tests { #[tokio::test] async fn shell_backend_rejects_command_escaping_sandbox() { let dir = tempfile::tempdir().unwrap(); - let b = ShellBackend::new(dir.path().into()); + let b = ShellBackend::new(dir.path().into(), 90); let mut job = crate::supervisor::job::Job::new( "t", crate::supervisor::job::JobType::ShellJob, @@ -132,4 +235,55 @@ mod tests { crate::supervisor::job::JobStatus::Failed )); } + + #[tokio::test] + async fn shell_backend_timeout_kills_and_respects_config() { + let dir = tempfile::tempdir().unwrap(); + let b = ShellBackend::new(dir.path().into(), 1); + let mut job = crate::supervisor::job::Job::new( + "t", + crate::supervisor::job::JobType::ShellJob, + "shell", + "sleep 30", + ); + job.prompt = Some("sleep 30".into()); + job.timeout_secs = 600; + let start = std::time::Instant::now(); + let out = b.run(&mut job, &RunContext::new()).await.unwrap(); + assert!(start.elapsed() < Duration::from_secs(5)); + assert!(matches!(out.status, JobStatus::Failed)); + assert!(out.errors.iter().any(|e| e.contains("timed out"))); + } + + #[tokio::test] + async fn shell_backend_large_stdout_does_not_deadlock() { + let dir = tempfile::tempdir().unwrap(); + let b = ShellBackend::new(dir.path().into(), 10); + // ~200KB of output β€” would fill the pipe and hang wait() without concurrent drain. + let mut job = crate::supervisor::job::Job::new( + "t", + crate::supervisor::job::JobType::ShellJob, + "shell", + "large", + ); + job.prompt = Some("python3 -c \"print('x'*200000)\"".into()); + job.timeout_secs = 10; + let start = std::time::Instant::now(); + let out = b.run(&mut job, &RunContext::new()).await.unwrap(); + assert!( + start.elapsed() < Duration::from_secs(8), + "large stdout took too long (possible pipe deadlock)" + ); + assert!(matches!(out.status, JobStatus::Succeeded)); + assert!(out.summary.len() >= 100_000); + } + + #[test] + fn effective_timeout_picks_tighter_cap() { + let b = ShellBackend::new(PathBuf::from("/tmp"), 90); + assert_eq!(b.effective_timeout_secs(600), 90); + assert_eq!(b.effective_timeout_secs(30), 30); + let unlimited = ShellBackend::new(PathBuf::from("/tmp"), 0); + assert_eq!(unlimited.effective_timeout_secs(600), 600); + } } diff --git a/src/supervisor/job.rs b/src/supervisor/job.rs index 4ed8514..3c7a8e2 100644 --- a/src/supervisor/job.rs +++ b/src/supervisor/job.rs @@ -55,6 +55,19 @@ pub struct JobOutput { pub next_step: Option, } +impl JobOutput { + pub fn failed(errors: Vec) -> Self { + Self { + status: JobStatus::Failed, + summary: String::new(), + evidence: vec![], + errors, + changed_files: vec![], + next_step: None, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Job { pub id: String, diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 3a19678..8f5c6b8 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,4 +1,5 @@ pub mod markdown_entities; +pub mod process; pub mod rich_sender; pub mod strings; pub mod telegram_markdown; diff --git a/src/utils/process.rs b/src/utils/process.rs new file mode 100644 index 0000000..39f341f --- /dev/null +++ b/src/utils/process.rs @@ -0,0 +1,72 @@ +//! Shared process helpers for sandboxed command execution. + +use std::time::Duration; +use tokio::io::{AsyncRead, AsyncReadExt}; +use tracing::warn; + +/// Future that sleeps `secs` seconds, or never resolves when `secs == 0` (no timeout). +pub async fn optional_timeout(secs: u64) { + if secs == 0 { + std::future::pending::<()>().await; + } else { + tokio::time::sleep(Duration::from_secs(secs)).await; + } +} + +/// Drain bytes from an async reader into an mpsc sender until EOF. +/// +/// Used for capturing `ChildStdout` / `ChildStderr` into the shared output +/// buffer during command execution. +pub async fn drain_pipe(mut reader: R, tx: tokio::sync::mpsc::Sender) +where + R: AsyncRead + Unpin, +{ + let mut buf = vec![0u8; 4096]; + loop { + match reader.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + let _ = tx + .send(String::from_utf8_lossy(&buf[..n]).to_string()) + .await; + } + } + } +} + +/// Like [`drain_pipe`] but wraps the entire future in a `timeout` so callers +/// are guaranteed not to hang when a daemon process inherits and holds open +/// the write end of the pipe. +/// +/// Returns `true` if the drain completed, `false` if it timed out. +pub async fn drain_pipe_timeout( + reader: R, + tx: tokio::sync::mpsc::Sender, + timeout_secs: u64, +) -> bool +where + R: AsyncRead + Unpin, +{ + tokio::time::timeout(Duration::from_secs(timeout_secs), drain_pipe(reader, tx)) + .await + .is_ok() +} + +/// SIGKILL the process group (Unix) then the child, and wait for exit. +pub async fn kill_child(child: &mut tokio::process::Child) { + #[cfg(unix)] + if let Some(pid) = child.id() { + if let Err(e) = nix::sys::signal::killpg( + nix::unistd::Pid::from_raw(pid as i32), + nix::sys::signal::Signal::SIGKILL, + ) { + warn!("killpg({pid}) failed: {e}"); + } + } + if let Err(e) = child.kill().await { + warn!("child.kill failed: {e}"); + } + if let Err(e) = child.wait().await { + warn!("child.wait after kill failed: {e}"); + } +}