From 9701aa73202981f37e6be2ae07a40c396a6db803 Mon Sep 17 00:00:00 2001 From: Shivakumar Date: Sat, 25 Jul 2026 13:54:31 +0530 Subject: [PATCH 01/13] vent: classify agentflare-core vs external vent origin --- src/vent/classify.rs | 54 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/vent/classify.rs b/src/vent/classify.rs index 3a1fe5b01..61beb0599 100644 --- a/src/vent/classify.rs +++ b/src/vent/classify.rs @@ -10,6 +10,24 @@ static MARKER_RE: LazyLock = LazyLock::new(|| { .expect("static marker regex is valid") }); +static ORIGIN_RE: LazyLock = LazyLock::new(|| { + Regex::new( + r"(?i)af-guard|agentflare git shim|orchestrator-managed by agentflare|item tracker is wired up for this repo", + ) + .expect("static origin regex is valid") +}); + +/// Classifies which pipeline a vent message belongs to: agentflare's own +/// tooling (git shim, af-guard, item-tracker redirects) vs. everything else. +/// Deliberately narrow so plain errors mentioning "git worktree" don't match. +pub fn origin(message: &str) -> &'static str { + if ORIGIN_RE.is_match(message) { + "agentflare-core" + } else { + "external" + } +} + pub fn topic_key(message: &str) -> String { message .to_lowercase() @@ -83,4 +101,40 @@ mod tests { assert_eq!(normalize_severity(Some("garbage")), "medium"); assert_eq!(normalize_severity(None), "medium"); } + + #[test] + fn origin_detects_agentflare_core_signatures() { + assert_eq!( + origin("af-guard's Bash hook blocked git merge-base --is-ancestor"), + "agentflare-core" + ); + assert_eq!( + origin("agentflare git shim: denied -- use item tool's claim flow"), + "agentflare-core" + ); + assert_eq!( + origin("'git worktree' is orchestrator-managed by agentflare"), + "agentflare-core" + ); + assert_eq!( + origin("agentflare-backend's item tracker is wired up for this repo -- use the `item` MCP tool"), + "agentflare-core" + ); + } + + #[test] + fn origin_defaults_to_external_for_unrelated_failures() { + assert_eq!( + origin("bash: a for loop expanded $d to every file in cwd"), + "external" + ); + assert_eq!( + origin("lean-ctx ctx_read failed with os error 5 on a directory"), + "external" + ); + assert_eq!( + origin("git worktree remove failed: Filename too long"), + "external", + ); + } } From 7c6b71810801d74470ae5f979efd45401e1ba46d Mon Sep 17 00:00:00 2001 From: Shivakumar Date: Sat, 25 Jul 2026 13:55:10 +0530 Subject: [PATCH 02/13] vent: add global (non-per-repo) paths for agentflare-core vents --- src/vent/paths.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/vent/paths.rs b/src/vent/paths.rs index 3625100b6..0438baf85 100644 --- a/src/vent/paths.rs +++ b/src/vent/paths.rs @@ -44,6 +44,24 @@ pub fn cursor_path() -> PathBuf { .join(format!("{}.cursor", repo_slug())) } +/// Global, cross-repo log for agentflare-core vents (git shim / af-guard / +/// item-tracker friction) — deliberately NOT repo-slugged like `log_path()`, +/// so the same underlying agentflare bug hit in different repos dedups into +/// one place instead of splitting across N per-project rows. +pub fn global_log_path() -> PathBuf { + crate::state::state_dir() + .join("vents") + .join("agentflare-core.jsonl") +} + +/// `topic_key -> {issue_url, filed_at}` for the global agentflare-core log. +/// A vent is either filed or it isn't — no separate cursor file is needed. +pub fn global_filed_path() -> PathBuf { + crate::state::state_dir() + .join("vents") + .join("agentflare-core.filed.json") +} + pub fn backend_db_path() -> PathBuf { crate::paths::home().join(".agentflare").join("backend.db") } @@ -75,4 +93,14 @@ mod tests { fn repo_key_is_stable() { assert_eq!(repo_key(), repo_key()); } + + #[test] + fn global_paths_are_siblings_and_not_repo_scoped() { + let log = global_log_path(); + let filed = global_filed_path(); + assert!(log.to_string_lossy().contains("agentflare-core.jsonl")); + assert!(filed.to_string_lossy().contains("agentflare-core.filed.json")); + assert_eq!(log.parent(), filed.parent()); + assert_eq!(global_log_path(), global_log_path()); + } } From e6492dda8af64371b0f70c02579041936266a4a6 Mon Sep 17 00:00:00 2001 From: Shivakumar Date: Sat, 25 Jul 2026 13:55:55 +0530 Subject: [PATCH 03/13] vent: throttle repeat vents and route by origin at capture time --- src/vent/capture.rs | 110 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/src/vent/capture.rs b/src/vent/capture.rs index 08ba703f7..b877deeb7 100644 --- a/src/vent/capture.rs +++ b/src/vent/capture.rs @@ -40,10 +40,120 @@ pub fn append( Ok(id) } +pub const THROTTLE_WINDOW_SECS: i64 = 300; +pub const THROTTLE_MAX_PER_WINDOW: usize = 1; + +fn recent_count_for_topic( + log_path: &Path, + topic_key: &str, + now: chrono::DateTime, +) -> usize { + let Ok(text) = std::fs::read_to_string(log_path) else { + return 0; + }; + text.lines() + .filter_map(|l| serde_json::from_str::(l).ok()) + .filter(|v| crate::vent::classify::topic_key(&v.message) == topic_key) + .filter(|v| { + chrono::DateTime::parse_from_rfc3339(&v.ts) + .map(|t| (now - t.with_timezone(&chrono::Utc)).num_seconds() < THROTTLE_WINDOW_SECS) + .unwrap_or(false) + }) + .count() +} + +/// Classifies origin, picks the destination log (global agentflare-core vs. +/// per-repo), throttles repeat vents of the same topic within +/// `THROTTLE_WINDOW_SECS`, and appends if not throttled. Returns +/// `(event_id, suppressed)` — `suppressed = true` means nothing was written. +pub fn append_routed( + session: Option<&str>, + severity: &str, + tags: &[String], + message: &str, +) -> std::io::Result<(String, bool)> { + let log_path = if crate::vent::classify::origin(message) == "agentflare-core" { + crate::vent::paths::global_log_path() + } else { + crate::vent::paths::log_path() + }; + let topic_key = crate::vent::classify::topic_key(message); + let now = chrono::Utc::now(); + if recent_count_for_topic(&log_path, &topic_key, now) >= THROTTLE_MAX_PER_WINDOW { + return Ok((crate::vent::event_id(message), true)); + } + let id = append(&log_path, session, severity, tags, message)?; + Ok((id, false)) +} + #[cfg(test)] mod tests { use super::*; + fn write_line_at(log: &std::path::Path, ts: &str, severity: &str, message: &str) { + let line = VentLine { + event_id: crate::vent::event_id(message), + ts: ts.to_string(), + session: None, + severity: severity.to_string(), + tags: vec![], + message: message.to_string(), + }; + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(log) + .unwrap(); + writeln!(f, "{}", serde_json::to_string(&line).unwrap()).unwrap(); + } + + #[test] + fn recent_count_ignores_lines_outside_the_window() { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("v.jsonl"); + write_line_at(&log, "2020-01-01T00:00:00Z", "high", "an old repeated failure"); + let now = chrono::Utc::now(); + let key = crate::vent::classify::topic_key("an old repeated failure"); + assert_eq!(recent_count_for_topic(&log, &key, now), 0); + } + + #[test] + fn recent_count_includes_lines_inside_the_window() { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("v.jsonl"); + let now = chrono::Utc::now(); + write_line_at(&log, &now.to_rfc3339(), "high", "a fresh repeated failure"); + let key = crate::vent::classify::topic_key("a fresh repeated failure"); + assert_eq!(recent_count_for_topic(&log, &key, now), 1); + } + + #[test] + fn append_routed_suppresses_second_identical_call_within_window() { + crate::paths::test_support::with_temp_home(|| { + let (_, suppressed1) = + append_routed(None, "high", &[], "the exact same af-guard bug").unwrap(); + let (_, suppressed2) = + append_routed(None, "high", &[], "the exact same af-guard bug").unwrap(); + assert!(!suppressed1, "first occurrence must be captured"); + assert!(suppressed2, "second identical vent within the window must be throttled"); + }); + } + + #[test] + fn append_routed_sends_agentflare_core_and_external_to_different_logs() { + crate::paths::test_support::with_temp_home(|| { + append_routed(None, "high", &[], "af-guard blocked a protected branch push").unwrap(); + append_routed(None, "high", &[], "a totally unrelated bash quoting bug").unwrap(); + let global_text = std::fs::read_to_string(crate::vent::paths::global_log_path()) + .unwrap_or_default(); + let repo_text = std::fs::read_to_string(crate::vent::paths::log_path()) + .unwrap_or_default(); + assert!(global_text.contains("af-guard")); + assert!(!repo_text.contains("af-guard")); + assert!(repo_text.contains("bash quoting bug")); + }); + } + #[test] fn append_writes_one_parseable_line_per_call() { let dir = tempfile::tempdir().unwrap(); From 06699ea91fe6531885d9d6937aa4df72ac64da33 Mon Sep 17 00:00:00 2001 From: Shivakumar Date: Sat, 25 Jul 2026 13:57:21 +0530 Subject: [PATCH 04/13] vent: route say/vent entry points through append_routed --- src/cli/vent.rs | 22 +++++++++++++++++++--- src/mcp_server.rs | 10 ++++------ src/vent/paths.rs | 3 +++ 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/cli/vent.rs b/src/cli/vent.rs index f0dfe4daf..d3b877342 100644 --- a/src/cli/vent.rs +++ b/src/cli/vent.rs @@ -33,9 +33,11 @@ pub fn run(args: VentArgs) { tags, } => { let severity = crate::vent::classify::normalize_severity(Some(&severity)); - let log = crate::vent::paths::log_path(); - match crate::vent::capture::append(&log, None, severity, &tags, message.trim()) { - Ok(id) => println!("vented {id}"), + match crate::vent::capture::append_routed(None, severity, &tags, message.trim()) { + Ok((id, true)) => { + println!("vent {id} suppressed (same friction already vented recently)") + } + Ok((id, false)) => println!("vented {id}"), Err(e) => eprintln!("vent failed: {e}"), } } @@ -104,6 +106,20 @@ pub fn run(args: VentArgs) { #[cfg(test)] mod tests { + #[test] + fn say_via_append_routed_marks_agentflare_core_message_as_suppressed_on_repeat() { + crate::paths::test_support::with_temp_home(|| { + let (_, first) = + crate::vent::capture::append_routed(None, "high", &[], "af-guard blocked a push") + .unwrap(); + let (_, second) = + crate::vent::capture::append_routed(None, "high", &[], "af-guard blocked a push") + .unwrap(); + assert!(!first); + assert!(second); + }); + } + #[test] fn append_then_read_back_roundtrips() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/mcp_server.rs b/src/mcp_server.rs index 4bdd24308..d8176b0fc 100644 --- a/src/mcp_server.rs +++ b/src/mcp_server.rs @@ -1185,12 +1185,10 @@ impl AgentflareMcp { } let severity = crate::vent::classify::normalize_severity(req.severity.as_deref()); let tags = req.tags.unwrap_or_default(); - let log = crate::vent::paths::log_path(); - let event_id = - crate::vent::capture::append(&log, None, severity, &tags, req.message.trim()).map_err( - |e| ErrorData::internal_error(format!("vent capture failed: {e}"), None), - )?; - Ok(serde_json::json!({ "ok": true, "event_id": event_id }).to_string()) + let (event_id, suppressed) = + crate::vent::capture::append_routed(None, severity, &tags, req.message.trim()) + .map_err(|e| ErrorData::internal_error(format!("vent capture failed: {e}"), None))?; + Ok(serde_json::json!({ "ok": true, "event_id": event_id, "suppressed": suppressed }).to_string()) } /// Rejects PR titles that don't start with a conventional-commit type, diff --git a/src/vent/paths.rs b/src/vent/paths.rs index 0438baf85..986a6f45f 100644 --- a/src/vent/paths.rs +++ b/src/vent/paths.rs @@ -96,6 +96,9 @@ mod tests { #[test] fn global_paths_are_siblings_and_not_repo_scoped() { + let _guard = agent_registry::detect::PATH_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); let log = global_log_path(); let filed = global_filed_path(); assert!(log.to_string_lossy().contains("agentflare-core.jsonl")); From 597055fd4972501a25759ae3109869b41dd74e2f Mon Sep 17 00:00:00 2001 From: Shivakumar Date: Sat, 25 Jul 2026 13:58:29 +0530 Subject: [PATCH 05/13] vent: stop dropping captured tags during consolidation --- src/vent/consolidate.rs | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/vent/consolidate.rs b/src/vent/consolidate.rs index 88ac5bae6..2b07e49f7 100644 --- a/src/vent/consolidate.rs +++ b/src/vent/consolidate.rs @@ -76,6 +76,7 @@ pub fn consolidate_core( severity: String, count: i64, first_event: String, + tags: Vec, } let mut groups: BTreeMap = BTreeMap::new(); for l in &lines { @@ -85,9 +86,11 @@ pub fn consolidate_core( severity: l.severity.clone(), count: 0, first_event: l.event_id.clone(), + tags: l.tags.clone(), }); g.count += 1; g.message = l.message.clone(); + g.tags = l.tags.clone(); if severity_rank(&l.severity) > severity_rank(&g.severity) { g.severity = l.severity.clone(); } @@ -95,13 +98,13 @@ pub fn consolidate_core( for (key, g) in groups { report.consolidated += g.count as usize; - let tags_json = "[]"; + let tags_json = serde_json::to_string(&g.tags).unwrap_or_else(|_| "[]".to_string()); let out = match agentflare_backend::vent::upsert( conn, project_id, &g.message, &g.severity, - tags_json, + &tags_json, &key, &g.first_event, g.count, @@ -300,4 +303,33 @@ mod tests { .unwrap(); assert_eq!(seen, 43); } + + #[test] + fn tags_survive_consolidation_instead_of_being_dropped() { + let conn = open_in_memory().unwrap(); + let (p, s) = seed(&conn); + let dir = tempfile::tempdir().unwrap(); + let (log, cur) = (dir.path().join("v.jsonl"), dir.path().join("v.cursor")); + use std::io::Write; + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log) + .unwrap(); + let line = crate::vent::capture::VentLine { + event_id: crate::vent::event_id("tagged failure"), + ts: "t".into(), + session: None, + severity: "high".to_string(), + tags: vec!["dx".to_string()], + message: "tagged failure".to_string(), + }; + writeln!(f, "{}", serde_json::to_string(&line).unwrap()).unwrap(); + drop(f); + consolidate_core(&conn, &p, &s, &log, &cur).unwrap(); + let tags: String = conn + .query_row("SELECT tags FROM vents", [], |r| r.get(0)) + .unwrap(); + assert_eq!(tags, r#"["dx"]"#, "tags must flow through, not be hardcoded to []"); + } } From da2accb010b3dad4f53eb945bdeaef14b756f15e Mon Sep 17 00:00:00 2001 From: Shivakumar Date: Sat, 25 Jul 2026 13:59:22 +0530 Subject: [PATCH 06/13] vent: add batched pending/filed tracking for agentflare-core vents --- src/vent/file.rs | 174 +++++++++++++++++++++++++++++++++++++++++++++++ src/vent/mod.rs | 1 + 2 files changed, 175 insertions(+) create mode 100644 src/vent/file.rs diff --git a/src/vent/file.rs b/src/vent/file.rs new file mode 100644 index 000000000..f1f5b51eb --- /dev/null +++ b/src/vent/file.rs @@ -0,0 +1,174 @@ +use crate::vent::capture::VentLine; +use crate::vent::classify::{classify, severity_rank, topic_key}; +use std::collections::BTreeMap; +use std::path::Path; + +#[derive(Debug, Clone, serde::Serialize)] +pub struct PendingGroup { + pub topic_key: String, + pub message: String, + pub severity: String, + pub seen_count: i64, +} + +#[derive(Debug, Default, serde::Serialize, serde::Deserialize)] +struct FiledEntry { + issue_url: String, + filed_at: i64, +} + +fn read_all_lines(log_path: &Path) -> Vec { + let Ok(text) = std::fs::read_to_string(log_path) else { + return Vec::new(); + }; + text.lines() + .filter_map(|l| serde_json::from_str::(l).ok()) + .collect() +} + +fn load_filed(filed_path: &Path) -> BTreeMap { + let Ok(text) = std::fs::read_to_string(filed_path) else { + return BTreeMap::new(); + }; + serde_json::from_str(&text).unwrap_or_default() +} + +fn save_filed(filed_path: &Path, filed: &BTreeMap) -> std::io::Result<()> { + if let Some(parent) = filed_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(filed_path, serde_json::to_string_pretty(filed)?) +} + +/// Groups the global agentflare-core log by topic, applies the same +/// actionability rule the per-project pipeline uses, and excludes anything +/// already recorded in `filed_path`. +pub fn pending_batch(log_path: &Path, filed_path: &Path) -> Vec { + let lines = read_all_lines(log_path); + let filed = load_filed(filed_path); + let mut groups: BTreeMap = BTreeMap::new(); + for l in &lines { + let key = topic_key(&l.message); + let g = groups.entry(key.clone()).or_insert_with(|| PendingGroup { + topic_key: key.clone(), + message: l.message.clone(), + severity: l.severity.clone(), + seen_count: 0, + }); + g.seen_count += 1; + g.message = l.message.clone(); + if severity_rank(&l.severity) > severity_rank(&g.severity) { + g.severity = l.severity.clone(); + } + } + groups + .into_values() + .filter(|g| classify(&g.severity, g.seen_count, &g.message)) + .filter(|g| !filed.contains_key(&g.topic_key)) + .collect() +} + +/// Records every topic in `topic_keys` as filed under `issue_url`. All-or- +/// nothing at the call-site level: callers only invoke this after a confirmed +/// issue URL comes back from `create_github_issue`. +pub fn mark_filed( + filed_path: &Path, + topic_keys: &[String], + issue_url: &str, + filed_at: i64, +) -> std::io::Result<()> { + let mut filed = load_filed(filed_path); + for k in topic_keys { + filed.insert( + k.clone(), + FiledEntry { + issue_url: issue_url.to_string(), + filed_at, + }, + ); + } + save_filed(filed_path, &filed) +} + +/// Shells out to `gh issue create`. Not unit tested (matches this repo's existing +/// convention for subprocess calls) — exercised at the integration/manual level. +pub fn create_github_issue(title: &str, body: &str) -> std::io::Result { + let output = std::process::Command::new("gh") + .args([ + "issue", "create", "--repo", "getappz/agentflare", "--title", title, "--body", body, + ]) + .output()?; + if !output.status.success() { + return Err(std::io::Error::other( + String::from_utf8_lossy(&output.stderr).to_string(), + )); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vent::capture::VentLine; + use std::io::Write; + + fn write_lines(log: &Path, msgs: &[(&str, &str)]) { + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(log) + .unwrap(); + for (sev, m) in msgs { + let line = VentLine { + event_id: crate::vent::event_id(m), + ts: "2026-01-01T00:00:00Z".into(), + session: None, + severity: (*sev).to_string(), + tags: vec![], + message: (*m).to_string(), + }; + writeln!(f, "{}", serde_json::to_string(&line).unwrap()).unwrap(); + } + } + + #[test] + fn pending_batch_groups_by_topic_and_tracks_seen_count() { + let dir = tempfile::tempdir().unwrap(); + let (log, filed) = (dir.path().join("v.jsonl"), dir.path().join("v.filed.json")); + write_lines( + &log, + &[ + ("high", "af-guard blocked something"), + ("high", "af-guard blocked something"), + ("high", "a totally different bug"), + ], + ); + let batch = pending_batch(&log, &filed); + assert_eq!(batch.len(), 2, "two distinct topics"); + let guard_group = batch.iter().find(|g| g.message.contains("af-guard")).unwrap(); + assert_eq!(guard_group.seen_count, 2); + } + + #[test] + fn pending_batch_excludes_non_actionable_noise() { + let dir = tempfile::tempdir().unwrap(); + let (log, filed) = (dir.path().join("v.jsonl"), dir.path().join("v.filed.json")); + write_lines(&log, &[("low", "just a calm note")]); + assert!(pending_batch(&log, &filed).is_empty()); + } + + #[test] + fn mark_filed_removes_group_from_next_pending_batch() { + let dir = tempfile::tempdir().unwrap(); + let (log, filed) = (dir.path().join("v.jsonl"), dir.path().join("v.filed.json")); + write_lines(&log, &[("high", "af-guard blocked something")]); + let batch = pending_batch(&log, &filed); + assert_eq!(batch.len(), 1); + let keys: Vec = batch.iter().map(|g| g.topic_key.clone()).collect(); + mark_filed(&filed, &keys, "https://github.com/getappz/agentflare/issues/1", 1234).unwrap(); + assert!( + pending_batch(&log, &filed).is_empty(), + "already-filed topic must not reappear" + ); + } +} diff --git a/src/vent/mod.rs b/src/vent/mod.rs index eb8abefc2..d4cec418e 100644 --- a/src/vent/mod.rs +++ b/src/vent/mod.rs @@ -1,6 +1,7 @@ pub mod capture; pub mod classify; pub mod consolidate; +pub mod file; pub mod paths; pub fn event_id(message: &str) -> String { From 3b083a1efb71d21d665656b87f125ca255d8ead7 Mon Sep 17 00:00:00 2001 From: Shivakumar Date: Sat, 25 Jul 2026 14:00:11 +0530 Subject: [PATCH 07/13] vent: add 'agentflare vent file' batched issue-filing subcommand --- src/cli/vent.rs | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/cli/vent.rs b/src/cli/vent.rs index d3b877342..114193cc7 100644 --- a/src/cli/vent.rs +++ b/src/cli/vent.rs @@ -18,6 +18,14 @@ pub enum VentCmd { }, /// Triage buffered vents now (also run automatically once per turn). Consolidate, + /// List (or file, with --title/--body) pending agentflare-core vents as + /// ONE batched GitHub issue on getappz/agentflare. + File { + #[arg(long)] + title: Option, + #[arg(long)] + body: Option, + }, /// List triaged vents for this repo's project. List { #[arg(long)] @@ -60,6 +68,34 @@ pub fn run(args: VentArgs) { println!(" filed item {id}"); } } + VentCmd::File { title, body } => { + let log = crate::vent::paths::global_log_path(); + let filed = crate::vent::paths::global_filed_path(); + let batch = crate::vent::file::pending_batch(&log, &filed); + if batch.is_empty() { + println!("nothing to file"); + return; + } + let (Some(title), Some(body)) = (title, body) else { + println!("{} pending agentflare-core vent(s):", batch.len()); + for g in &batch { + println!(" [{}] seen×{} {}", g.severity, g.seen_count, g.message); + } + println!("re-run with --title and --body to file these as one GitHub issue"); + return; + }; + match crate::vent::file::create_github_issue(&title, &body) { + Ok(url) => { + let keys: Vec = batch.iter().map(|g| g.topic_key.clone()).collect(); + let now = chrono::Utc::now().timestamp(); + match crate::vent::file::mark_filed(&filed, &keys, &url, now) { + Ok(()) => println!("filed {url} ({} vent(s))", keys.len()), + Err(e) => eprintln!("filed issue but failed to record state: {e}"), + } + } + Err(e) => eprintln!("gh issue create failed: {e}"), + } + } VentCmd::List { actionable } => { let conn = match agentflare_backend::db::open_db(&crate::vent::paths::backend_db_path()) { @@ -106,6 +142,14 @@ pub fn run(args: VentArgs) { #[cfg(test)] mod tests { + #[test] + fn file_command_reports_nothing_when_batch_is_empty() { + let dir = tempfile::tempdir().unwrap(); + let (log, filed) = (dir.path().join("v.jsonl"), dir.path().join("v.filed.json")); + let batch = crate::vent::file::pending_batch(&log, &filed); + assert!(batch.is_empty(), "no log file yet means nothing pending"); + } + #[test] fn say_via_append_routed_marks_agentflare_core_message_as_suppressed_on_repeat() { crate::paths::test_support::with_temp_home(|| { From 00f754ccec4e1f2ed5d715d4f3562c0a14ef6276 Mon Sep 17 00:00:00 2001 From: Shivakumar Date: Sat, 25 Jul 2026 14:01:13 +0530 Subject: [PATCH 08/13] vent: expose batched agentflare-core issue filing as an MCP tool --- src/mcp_server.rs | 27 +++++++++++++++++++++++++++ src/mcp_server/types.rs | 8 ++++++++ 2 files changed, 35 insertions(+) diff --git a/src/mcp_server.rs b/src/mcp_server.rs index d8176b0fc..1053ec436 100644 --- a/src/mcp_server.rs +++ b/src/mcp_server.rs @@ -1191,6 +1191,33 @@ impl AgentflareMcp { Ok(serde_json::json!({ "ok": true, "event_id": event_id, "suppressed": suppressed }).to_string()) } + #[tool( + description = "List or file agentflare's own tooling bugs (git shim, af-guard, item-tracker friction -- NOT this project's own vents) as ONE batched GitHub issue on getappz/agentflare. Call without title/body to see the pending batch and write a summary yourself; call again with both to file it." + )] + fn vent_file(&self, Parameters(req): Parameters) -> Result { + let log = crate::vent::paths::global_log_path(); + let filed = crate::vent::paths::global_filed_path(); + let batch = crate::vent::file::pending_batch(&log, &filed); + if batch.is_empty() { + return Ok(serde_json::json!({ "ok": true, "pending": [] }).to_string()); + } + match (req.title, req.body) { + (Some(title), Some(body)) => { + let url = crate::vent::file::create_github_issue(&title, &body).map_err(|e| { + ErrorData::internal_error(format!("gh issue create failed: {e}"), None) + })?; + let keys: Vec = batch.iter().map(|g| g.topic_key.clone()).collect(); + let now = chrono::Utc::now().timestamp(); + crate::vent::file::mark_filed(&filed, &keys, &url, now).map_err(|e| { + ErrorData::internal_error(format!("mark_filed failed: {e}"), None) + })?; + Ok(serde_json::json!({ "ok": true, "issue_url": url, "filed_count": keys.len() }) + .to_string()) + } + _ => Ok(serde_json::json!({ "ok": true, "pending": batch }).to_string()), + } + } + /// Rejects PR titles that don't start with a conventional-commit type, /// mirroring `.github/workflows/pr-title.yml`'s /// `amannn/action-semantic-pull-request` config so the check fires here diff --git a/src/mcp_server/types.rs b/src/mcp_server/types.rs index 61cfff677..aaccb5bb2 100644 --- a/src/mcp_server/types.rs +++ b/src/mcp_server/types.rs @@ -326,6 +326,14 @@ pub(crate) struct VentRequest { pub(crate) tags: Option>, } +#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] +pub(crate) struct VentFileRequest { + #[serde(default)] + pub(crate) title: Option, + #[serde(default)] + pub(crate) body: Option, +} + #[derive(Debug, Default, Deserialize, schemars::JsonSchema)] pub(crate) struct MemoryRequest { #[schemars(description = "Action: compact|context|curate|handoff|recall|relate|remember")] From fc354b78a23793fcaaaba4d7a03c0b6679ce266c Mon Sep 17 00:00:00 2001 From: Shivakumar Date: Sat, 25 Jul 2026 14:02:50 +0530 Subject: [PATCH 09/13] init: wire a refined PostToolUseFailure judge-prompt hook --- src/init.rs | 58 ++++++++++++++++++++++++++++++++++++++++++++++++ src/rule_text.rs | 21 ++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/src/init.rs b/src/init.rs index 16a5a881f..106d92e3f 100644 --- a/src/init.rs +++ b/src/init.rs @@ -198,6 +198,32 @@ fn add_hook_entry( true } +/// Adds one prompt-type hook entry for `event` unless an entry containing +/// `marker` is already present. Same idempotency pattern as `add_hook_entry` +/// but for `"type": "prompt"` hooks (used for PostToolUseFailure judge). +fn add_prompt_hook_entry( + hooks_obj: &mut Map, + event: &str, + marker: &str, + matcher: &str, + prompt: &str, + timeout: u64, +) -> bool { + let arr = hooks_obj + .entry(event) + .or_insert_with(|| json!([])) + .as_array_mut() + .unwrap(); + if arr.iter().any(|v| v.to_string().contains(marker)) { + return false; + } + arr.push(json!({ + "matcher": matcher, + "hooks": [{ "type": "prompt", "prompt": prompt, "timeout": timeout, "continueOnBlock": true }] + })); + true +} + fn wire_claude_code() { let path = claude_settings_path(); let mut settings = read_json_object(&path, || json!({})); @@ -236,6 +262,14 @@ fn wire_claude_code() { format!("\"{bin}\" hook pre-compact"), 5, ); + added |= add_prompt_hook_entry( + hooks_obj, + "PostToolUseFailure", + "genuine FRICTION", + "Bash|Edit|Write", + crate::rule_text::VENT_JUDGE_PROMPT, + 15, + ); if !added { ui::skip("~/.claude/settings.json hooks (already wired)"); @@ -695,6 +729,7 @@ mod tests { assert!(content.contains("UserPromptSubmit")); assert!(content.contains("PreToolUse")); assert!(content.contains("PreCompact")); + assert!(content.contains("PostToolUseFailure")); }); } @@ -749,6 +784,29 @@ mod tests { }); } + #[test] + fn wire_claude_code_wires_post_tool_use_failure_judge_prompt() { + with_temp_home(|| { + wire_claude_code(); + let content = fs::read_to_string(home().join(".claude").join("settings.json")).unwrap(); + assert!( + content.contains("genuine FRICTION"), + "expected the judge-prompt marker in PostToolUseFailure hooks" + ); + }); + } + + #[test] + fn wire_claude_code_post_tool_use_failure_is_idempotent() { + with_temp_home(|| { + wire_claude_code(); + let first = fs::read_to_string(home().join(".claude").join("settings.json")).unwrap(); + wire_claude_code(); + let second = fs::read_to_string(home().join(".claude").join("settings.json")).unwrap(); + assert_eq!(first, second, "second run must not duplicate PostToolUseFailure"); + }); + } + #[test] fn wire_claude_code_backfills_pre_tool_use_into_already_wired_install() { with_temp_home(|| { diff --git a/src/rule_text.rs b/src/rule_text.rs index 04677de54..148436360 100644 --- a/src/rule_text.rs +++ b/src/rule_text.rs @@ -52,6 +52,27 @@ pub fn all() -> Vec<&'static str> { /// config registration needed). Shells out to the same `agentflare hook /// pre-tool-use` classifier Claude Code's PreToolUse hook already uses, so /// there's one branch-guard decision, not a JS reimplementation of it. +/// PostToolUseFailure judge prompt: decides whether a tool failure is +/// genuine friction worth a vent call. Explicitly separates "a guard blocked +/// exactly what it exists to block, cleanly" (not friction) from "a guard +/// blocked me in a confusing or undocumented way" (friction). +pub const VENT_JUDGE_PROMPT: &str = r#"A tool call just failed. Tool call details: +$ARGUMENTS + +Judge whether this is genuine FRICTION worth logging via agentflare's vent tool (mcp__flare__vent), using the SAME broad definition its own classifier uses -- not just "wrong/missing tool": +- severity is high / this genuinely blocked progress, OR +- the situation involves a pattern like broke/broken/fails/failing/wrong/should/missing/can't/cannot/error/panic/crash/hang/stuck/fabricated, OR +- this is a repeat of a problem that already happened earlier this session. + +This covers a wrong or missing tool, a fabricated assumption about the environment, an environment/tooling gap, a confusing or misleading error message, or a tool silently doing the wrong thing -- not just outright blocking failures. + +NOT friction: an ordinary expected failure (a grep with no matches, a file legitimately absent during exploration, a normal retry, slow builds, a routine denied permission), OR a guard/hook that blocked exactly the thing it exists to block, with a clear reason and a discoverable next step (e.g. a branch-protection guard refusing a write to main, or a destructive-command guard refusing rm -rf) -- that guard did its job; it is not a bug. + +Friction IS still the right call when a guard's block is confusing or its workaround is undocumented -- e.g. it names an alternative that doesn't exist, or leaves you guessing which action it actually wants. + +If this is genuine friction: respond NOT ok, with a reason that nudges calling mcp__flare__vent with a concise, specific message (state the actual wrong tool/assumption/gap, not a vague complaint) and an appropriate severity. +If this is an ordinary failure or a guard working as intended: respond ok."#; + pub const OPENCODE_BRANCH_GUARD_JS: &str = r#"// opencode has no PreToolUse hook wired to agentflare's own PreToolUse guard // (that's Claude-Code-only, ~/.claude/settings.json), and opencode's own // permission config isn't branch-aware -- so write/edit/patch tools went From ab95895cb8fc2715c7c16f1dcf3c81357b37e40b Mon Sep 17 00:00:00 2001 From: Shivakumar Date: Sat, 25 Jul 2026 14:21:53 +0530 Subject: [PATCH 10/13] vent: fix fabricated event_id on suppressed vent, reject partial file args append_routed's throttled path called event_id(message) again to build its return value, but event_id() salts with the current timestamp, so the id returned for a suppressed vent never matched any logged entry. It now returns the real id of the matching recent log line instead. Also stop silently listing pending vents when only one of --title/--body (or MCP title/body) is given to vent file -- treat that as an error instead of quietly ignoring the partial input. --- src/cli/vent.rs | 23 +++++++++++++++++------ src/mcp_server.rs | 10 +++++++++- src/vent/capture.rs | 40 ++++++++++++++++++++++++++++++++-------- 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/src/cli/vent.rs b/src/cli/vent.rs index 114193cc7..c401cef72 100644 --- a/src/cli/vent.rs +++ b/src/cli/vent.rs @@ -76,13 +76,24 @@ pub fn run(args: VentArgs) { println!("nothing to file"); return; } - let (Some(title), Some(body)) = (title, body) else { - println!("{} pending agentflare-core vent(s):", batch.len()); - for g in &batch { - println!(" [{}] seen×{} {}", g.severity, g.seen_count, g.message); + let (title, body) = match (title, body) { + (Some(title), Some(body)) => (title, body), + (None, None) => { + println!("{} pending agentflare-core vent(s):", batch.len()); + for g in &batch { + println!(" [{}] seen×{} {}", g.severity, g.seen_count, g.message); + } + println!("re-run with --title and --body to file these as one GitHub issue"); + return; + } + (title, body) => { + eprintln!( + "vent file: need both --title and --body to file (got title={}, body={})", + title.is_some(), + body.is_some() + ); + return; } - println!("re-run with --title and --body to file these as one GitHub issue"); - return; }; match crate::vent::file::create_github_issue(&title, &body) { Ok(url) => { diff --git a/src/mcp_server.rs b/src/mcp_server.rs index 1053ec436..4d2f47914 100644 --- a/src/mcp_server.rs +++ b/src/mcp_server.rs @@ -1214,7 +1214,15 @@ impl AgentflareMcp { Ok(serde_json::json!({ "ok": true, "issue_url": url, "filed_count": keys.len() }) .to_string()) } - _ => Ok(serde_json::json!({ "ok": true, "pending": batch }).to_string()), + (None, None) => Ok(serde_json::json!({ "ok": true, "pending": batch }).to_string()), + (title, body) => Err(ErrorData::invalid_params( + format!( + "vent_file needs both title and body to file (got title={}, body={}), or neither to list pending", + title.is_some(), + body.is_some() + ), + None, + )), } } diff --git a/src/vent/capture.rs b/src/vent/capture.rs index b877deeb7..ce2cf2f32 100644 --- a/src/vent/capture.rs +++ b/src/vent/capture.rs @@ -43,13 +43,13 @@ pub fn append( pub const THROTTLE_WINDOW_SECS: i64 = 300; pub const THROTTLE_MAX_PER_WINDOW: usize = 1; -fn recent_count_for_topic( +fn recent_matches_for_topic( log_path: &Path, topic_key: &str, now: chrono::DateTime, -) -> usize { +) -> Vec { let Ok(text) = std::fs::read_to_string(log_path) else { - return 0; + return Vec::new(); }; text.lines() .filter_map(|l| serde_json::from_str::(l).ok()) @@ -59,7 +59,7 @@ fn recent_count_for_topic( .map(|t| (now - t.with_timezone(&chrono::Utc)).num_seconds() < THROTTLE_WINDOW_SECS) .unwrap_or(false) }) - .count() + .collect() } /// Classifies origin, picks the destination log (global agentflare-core vs. @@ -79,8 +79,16 @@ pub fn append_routed( }; let topic_key = crate::vent::classify::topic_key(message); let now = chrono::Utc::now(); - if recent_count_for_topic(&log_path, &topic_key, now) >= THROTTLE_MAX_PER_WINDOW { - return Ok((crate::vent::event_id(message), true)); + let matches = recent_matches_for_topic(&log_path, &topic_key, now); + if matches.len() >= THROTTLE_MAX_PER_WINDOW { + // Reference the real, already-logged event instead of minting a + // fresh id — `event_id()` is time-salted, so a second call would + // return an id that was never written anywhere. + let id = matches + .last() + .map(|v| v.event_id.clone()) + .unwrap_or_else(|| crate::vent::event_id(message)); + return Ok((id, true)); } let id = append(&log_path, session, severity, tags, message)?; Ok((id, false)) @@ -114,7 +122,7 @@ mod tests { write_line_at(&log, "2020-01-01T00:00:00Z", "high", "an old repeated failure"); let now = chrono::Utc::now(); let key = crate::vent::classify::topic_key("an old repeated failure"); - assert_eq!(recent_count_for_topic(&log, &key, now), 0); + assert_eq!(recent_matches_for_topic(&log, &key, now).len(), 0); } #[test] @@ -124,7 +132,7 @@ mod tests { let now = chrono::Utc::now(); write_line_at(&log, &now.to_rfc3339(), "high", "a fresh repeated failure"); let key = crate::vent::classify::topic_key("a fresh repeated failure"); - assert_eq!(recent_count_for_topic(&log, &key, now), 1); + assert_eq!(recent_matches_for_topic(&log, &key, now).len(), 1); } #[test] @@ -139,6 +147,22 @@ mod tests { }); } + #[test] + fn append_routed_suppressed_call_returns_original_event_id() { + crate::paths::test_support::with_temp_home(|| { + let (first_id, suppressed1) = + append_routed(None, "high", &[], "the exact same af-guard bug").unwrap(); + let (second_id, suppressed2) = + append_routed(None, "high", &[], "the exact same af-guard bug").unwrap(); + assert!(!suppressed1); + assert!(suppressed2); + assert_eq!( + second_id, first_id, + "suppressed call must reference the real logged event, not a fabricated id" + ); + }); + } + #[test] fn append_routed_sends_agentflare_core_and_external_to_different_logs() { crate::paths::test_support::with_temp_home(|| { From 337a7a88562b3aae746fff2b81d6bc126bb7db55 Mon Sep 17 00:00:00 2001 From: Shivakumar Date: Sat, 25 Jul 2026 14:29:23 +0530 Subject: [PATCH 11/13] vent: expire filed agentflare-core topics after 30 days pending_batch excluded any topic_key ever recorded in filed_path, so a bug that regresses (or was never actually fixed) after its issue is filed would never resurface for re-filing. Add a 30-day TTL (FILED_TTL_SECS) so a filed topic becomes eligible again once enough time has passed, based purely on the existing filed_at timestamp -- no extra gh calls needed. --- src/cli/vent.rs | 5 +++-- src/mcp_server.rs | 3 ++- src/vent/file.rs | 51 ++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/src/cli/vent.rs b/src/cli/vent.rs index c401cef72..609ce582b 100644 --- a/src/cli/vent.rs +++ b/src/cli/vent.rs @@ -71,7 +71,8 @@ pub fn run(args: VentArgs) { VentCmd::File { title, body } => { let log = crate::vent::paths::global_log_path(); let filed = crate::vent::paths::global_filed_path(); - let batch = crate::vent::file::pending_batch(&log, &filed); + let batch = + crate::vent::file::pending_batch(&log, &filed, chrono::Utc::now().timestamp()); if batch.is_empty() { println!("nothing to file"); return; @@ -157,7 +158,7 @@ mod tests { fn file_command_reports_nothing_when_batch_is_empty() { let dir = tempfile::tempdir().unwrap(); let (log, filed) = (dir.path().join("v.jsonl"), dir.path().join("v.filed.json")); - let batch = crate::vent::file::pending_batch(&log, &filed); + let batch = crate::vent::file::pending_batch(&log, &filed, chrono::Utc::now().timestamp()); assert!(batch.is_empty(), "no log file yet means nothing pending"); } diff --git a/src/mcp_server.rs b/src/mcp_server.rs index 4d2f47914..be28dc1d3 100644 --- a/src/mcp_server.rs +++ b/src/mcp_server.rs @@ -1197,7 +1197,8 @@ impl AgentflareMcp { fn vent_file(&self, Parameters(req): Parameters) -> Result { let log = crate::vent::paths::global_log_path(); let filed = crate::vent::paths::global_filed_path(); - let batch = crate::vent::file::pending_batch(&log, &filed); + let batch = + crate::vent::file::pending_batch(&log, &filed, chrono::Utc::now().timestamp()); if batch.is_empty() { return Ok(serde_json::json!({ "ok": true, "pending": [] }).to_string()); } diff --git a/src/vent/file.rs b/src/vent/file.rs index f1f5b51eb..69789c123 100644 --- a/src/vent/file.rs +++ b/src/vent/file.rs @@ -17,6 +17,11 @@ struct FiledEntry { filed_at: i64, } +/// How long a filed topic stays excluded from `pending_batch` before it's +/// eligible to resurface. Without this, a bug that regresses after its issue +/// is closed (or never actually gets fixed) would be hidden forever. +pub const FILED_TTL_SECS: i64 = 30 * 24 * 60 * 60; + fn read_all_lines(log_path: &Path) -> Vec { let Ok(text) = std::fs::read_to_string(log_path) else { return Vec::new(); @@ -42,8 +47,10 @@ fn save_filed(filed_path: &Path, filed: &BTreeMap) -> std::i /// Groups the global agentflare-core log by topic, applies the same /// actionability rule the per-project pipeline uses, and excludes anything -/// already recorded in `filed_path`. -pub fn pending_batch(log_path: &Path, filed_path: &Path) -> Vec { +/// recorded in `filed_path` less than `FILED_TTL_SECS` ago — a topic filed +/// longer than that resurfaces so a recurring or unfixed bug isn't hidden +/// forever behind a single old issue. +pub fn pending_batch(log_path: &Path, filed_path: &Path, now: i64) -> Vec { let lines = read_all_lines(log_path); let filed = load_filed(filed_path); let mut groups: BTreeMap = BTreeMap::new(); @@ -64,7 +71,10 @@ pub fn pending_batch(log_path: &Path, filed_path: &Path) -> Vec { groups .into_values() .filter(|g| classify(&g.severity, g.seen_count, &g.message)) - .filter(|g| !filed.contains_key(&g.topic_key)) + .filter(|g| match filed.get(&g.topic_key) { + Some(entry) => now - entry.filed_at >= FILED_TTL_SECS, + None => true, + }) .collect() } @@ -143,7 +153,7 @@ mod tests { ("high", "a totally different bug"), ], ); - let batch = pending_batch(&log, &filed); + let batch = pending_batch(&log, &filed, 0); assert_eq!(batch.len(), 2, "two distinct topics"); let guard_group = batch.iter().find(|g| g.message.contains("af-guard")).unwrap(); assert_eq!(guard_group.seen_count, 2); @@ -154,7 +164,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let (log, filed) = (dir.path().join("v.jsonl"), dir.path().join("v.filed.json")); write_lines(&log, &[("low", "just a calm note")]); - assert!(pending_batch(&log, &filed).is_empty()); + assert!(pending_batch(&log, &filed, 0).is_empty()); } #[test] @@ -162,13 +172,36 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let (log, filed) = (dir.path().join("v.jsonl"), dir.path().join("v.filed.json")); write_lines(&log, &[("high", "af-guard blocked something")]); - let batch = pending_batch(&log, &filed); + let now = 1_700_000_000; + let batch = pending_batch(&log, &filed, now); assert_eq!(batch.len(), 1); let keys: Vec = batch.iter().map(|g| g.topic_key.clone()).collect(); - mark_filed(&filed, &keys, "https://github.com/getappz/agentflare/issues/1", 1234).unwrap(); + mark_filed(&filed, &keys, "https://github.com/getappz/agentflare/issues/1", now).unwrap(); + assert!( + pending_batch(&log, &filed, now + 60).is_empty(), + "already-filed topic must not reappear within the TTL window" + ); + } + + #[test] + fn pending_batch_allows_refiling_after_ttl_expires() { + let dir = tempfile::tempdir().unwrap(); + let (log, filed) = (dir.path().join("v.jsonl"), dir.path().join("v.filed.json")); + write_lines(&log, &[("high", "af-guard blocked something")]); + let filed_at = 1_700_000_000; + let keys = vec![topic_key("af-guard blocked something")]; + mark_filed( + &filed, + &keys, + "https://github.com/getappz/agentflare/issues/1", + filed_at, + ) + .unwrap(); assert!( - pending_batch(&log, &filed).is_empty(), - "already-filed topic must not reappear" + pending_batch(&log, &filed, filed_at + FILED_TTL_SECS - 1).is_empty(), + "still within the TTL window" ); + let batch = pending_batch(&log, &filed, filed_at + FILED_TTL_SECS + 1); + assert_eq!(batch.len(), 1, "must resurface once the TTL has elapsed"); } } From 5fdeaa614529edda76f6d69d765bdc02ee5aed9f Mon Sep 17 00:00:00 2001 From: Shivakumar Date: Sat, 25 Jul 2026 15:33:55 +0530 Subject: [PATCH 12/13] vent: cargo fmt (fixes CI fmt check) --- src/init.rs | 5 ++++- src/vent/classify.rs | 4 +++- src/vent/consolidate.rs | 5 ++++- src/vent/paths.rs | 6 +++++- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/init.rs b/src/init.rs index 106d92e3f..0cb4472ee 100644 --- a/src/init.rs +++ b/src/init.rs @@ -803,7 +803,10 @@ mod tests { let first = fs::read_to_string(home().join(".claude").join("settings.json")).unwrap(); wire_claude_code(); let second = fs::read_to_string(home().join(".claude").join("settings.json")).unwrap(); - assert_eq!(first, second, "second run must not duplicate PostToolUseFailure"); + assert_eq!( + first, second, + "second run must not duplicate PostToolUseFailure" + ); }); } diff --git a/src/vent/classify.rs b/src/vent/classify.rs index 61beb0599..6b5c7a80b 100644 --- a/src/vent/classify.rs +++ b/src/vent/classify.rs @@ -117,7 +117,9 @@ mod tests { "agentflare-core" ); assert_eq!( - origin("agentflare-backend's item tracker is wired up for this repo -- use the `item` MCP tool"), + origin( + "agentflare-backend's item tracker is wired up for this repo -- use the `item` MCP tool" + ), "agentflare-core" ); } diff --git a/src/vent/consolidate.rs b/src/vent/consolidate.rs index 2b07e49f7..2edc031a9 100644 --- a/src/vent/consolidate.rs +++ b/src/vent/consolidate.rs @@ -330,6 +330,9 @@ mod tests { let tags: String = conn .query_row("SELECT tags FROM vents", [], |r| r.get(0)) .unwrap(); - assert_eq!(tags, r#"["dx"]"#, "tags must flow through, not be hardcoded to []"); + assert_eq!( + tags, r#"["dx"]"#, + "tags must flow through, not be hardcoded to []" + ); } } diff --git a/src/vent/paths.rs b/src/vent/paths.rs index 986a6f45f..3ec06db89 100644 --- a/src/vent/paths.rs +++ b/src/vent/paths.rs @@ -102,7 +102,11 @@ mod tests { let log = global_log_path(); let filed = global_filed_path(); assert!(log.to_string_lossy().contains("agentflare-core.jsonl")); - assert!(filed.to_string_lossy().contains("agentflare-core.filed.json")); + assert!( + filed + .to_string_lossy() + .contains("agentflare-core.filed.json") + ); assert_eq!(log.parent(), filed.parent()); assert_eq!(global_log_path(), global_log_path()); } From d6e3851bd3271381a68d8388ea387e97ff1c5859 Mon Sep 17 00:00:00 2001 From: Shivakumar Date: Sat, 25 Jul 2026 15:34:08 +0530 Subject: [PATCH 13/13] vent: address CodeRabbit findings on PR #331 - recent_matches_for_topic: exclude future-dated log entries instead of letting a negative age satisfy "< THROTTLE_WINDOW_SECS" by sign alone. - save_filed: write via temp file + rename so a killed process can't leave filed.json truncated/corrupt. - create_github_issue: bound the gh subprocess with a 30s timeout (reusing ipc::process::run_with_timeout) so a hung gh can't block the calling MCP tool call or CLI invocation forever. - CLI and MCP vent-file paths: surface the created issue_url even when mark_filed fails afterward, instead of only reporting the write error -- losing the URL there was the only way an operator could avoid filing a duplicate issue on retry. --- src/cli/vent.rs | 5 ++++- src/ipc/process.rs | 1 - src/mcp_server.rs | 39 +++++++++++++++++++++++++-------- src/vent/capture.rs | 48 +++++++++++++++++++++++++++++++++------- src/vent/file.rs | 53 ++++++++++++++++++++++++++++++++++++++------- 5 files changed, 119 insertions(+), 27 deletions(-) diff --git a/src/cli/vent.rs b/src/cli/vent.rs index 609ce582b..843b8fefb 100644 --- a/src/cli/vent.rs +++ b/src/cli/vent.rs @@ -102,7 +102,10 @@ pub fn run(args: VentArgs) { let now = chrono::Utc::now().timestamp(); match crate::vent::file::mark_filed(&filed, &keys, &url, now) { Ok(()) => println!("filed {url} ({} vent(s))", keys.len()), - Err(e) => eprintln!("filed issue but failed to record state: {e}"), + Err(e) => eprintln!( + "filed {url} but failed to record state ({e}) — re-filing will \ + create a duplicate issue until this is reconciled by hand" + ), } } Err(e) => eprintln!("gh issue create failed: {e}"), diff --git a/src/ipc/process.rs b/src/ipc/process.rs index 507488340..5086a01fa 100644 --- a/src/ipc/process.rs +++ b/src/ipc/process.rs @@ -151,7 +151,6 @@ fn parse_other_pids(raw: &str, self_pid: u32) -> Vec { pids } -#[allow(dead_code)] pub fn run_with_timeout(f: F, timeout: Duration) -> Result where F: FnOnce() -> T, diff --git a/src/mcp_server.rs b/src/mcp_server.rs index be28dc1d3..b6d4b6ecb 100644 --- a/src/mcp_server.rs +++ b/src/mcp_server.rs @@ -1187,8 +1187,13 @@ impl AgentflareMcp { let tags = req.tags.unwrap_or_default(); let (event_id, suppressed) = crate::vent::capture::append_routed(None, severity, &tags, req.message.trim()) - .map_err(|e| ErrorData::internal_error(format!("vent capture failed: {e}"), None))?; - Ok(serde_json::json!({ "ok": true, "event_id": event_id, "suppressed": suppressed }).to_string()) + .map_err(|e| { + ErrorData::internal_error(format!("vent capture failed: {e}"), None) + })?; + Ok( + serde_json::json!({ "ok": true, "event_id": event_id, "suppressed": suppressed }) + .to_string(), + ) } #[tool( @@ -1197,8 +1202,7 @@ impl AgentflareMcp { fn vent_file(&self, Parameters(req): Parameters) -> Result { let log = crate::vent::paths::global_log_path(); let filed = crate::vent::paths::global_filed_path(); - let batch = - crate::vent::file::pending_batch(&log, &filed, chrono::Utc::now().timestamp()); + let batch = crate::vent::file::pending_batch(&log, &filed, chrono::Utc::now().timestamp()); if batch.is_empty() { return Ok(serde_json::json!({ "ok": true, "pending": [] }).to_string()); } @@ -1209,11 +1213,28 @@ impl AgentflareMcp { })?; let keys: Vec = batch.iter().map(|g| g.topic_key.clone()).collect(); let now = chrono::Utc::now().timestamp(); - crate::vent::file::mark_filed(&filed, &keys, &url, now).map_err(|e| { - ErrorData::internal_error(format!("mark_filed failed: {e}"), None) - })?; - Ok(serde_json::json!({ "ok": true, "issue_url": url, "filed_count": keys.len() }) - .to_string()) + // The issue itself is already created at this point — even if + // mark_filed fails, the caller must still learn the issue_url + // (not just an opaque error), or the same batch gets re-filed + // as a duplicate issue on the next call. + match crate::vent::file::mark_filed(&filed, &keys, &url, now) { + Ok(()) => Ok(serde_json::json!({ + "ok": true, + "issue_url": url, + "filed_count": keys.len(), + }) + .to_string()), + Err(e) => Ok(serde_json::json!({ + "ok": true, + "issue_url": url, + "filed_count": keys.len(), + "warning": format!( + "issue created but failed to record filed-state ({e}) — re-filing \ + will create a duplicate issue until this is reconciled by hand" + ), + }) + .to_string()), + } } (None, None) => Ok(serde_json::json!({ "ok": true, "pending": batch }).to_string()), (title, body) => Err(ErrorData::invalid_params( diff --git a/src/vent/capture.rs b/src/vent/capture.rs index ce2cf2f32..3f4e2a736 100644 --- a/src/vent/capture.rs +++ b/src/vent/capture.rs @@ -56,7 +56,10 @@ fn recent_matches_for_topic( .filter(|v| crate::vent::classify::topic_key(&v.message) == topic_key) .filter(|v| { chrono::DateTime::parse_from_rfc3339(&v.ts) - .map(|t| (now - t.with_timezone(&chrono::Utc)).num_seconds() < THROTTLE_WINDOW_SECS) + .map(|t| { + let t = t.with_timezone(&chrono::Utc); + t <= now && (now - t).num_seconds() < THROTTLE_WINDOW_SECS + }) .unwrap_or(false) }) .collect() @@ -119,12 +122,32 @@ mod tests { fn recent_count_ignores_lines_outside_the_window() { let dir = tempfile::tempdir().unwrap(); let log = dir.path().join("v.jsonl"); - write_line_at(&log, "2020-01-01T00:00:00Z", "high", "an old repeated failure"); + write_line_at( + &log, + "2020-01-01T00:00:00Z", + "high", + "an old repeated failure", + ); let now = chrono::Utc::now(); let key = crate::vent::classify::topic_key("an old repeated failure"); assert_eq!(recent_matches_for_topic(&log, &key, now).len(), 0); } + #[test] + fn recent_count_ignores_future_dated_lines() { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("v.jsonl"); + let now = chrono::Utc::now(); + let future = now + chrono::Duration::seconds(60); + write_line_at(&log, &future.to_rfc3339(), "high", "a future-dated entry"); + let key = crate::vent::classify::topic_key("a future-dated entry"); + assert_eq!( + recent_matches_for_topic(&log, &key, now).len(), + 0, + "a future timestamp must not count as recent just because the age is negative" + ); + } + #[test] fn recent_count_includes_lines_inside_the_window() { let dir = tempfile::tempdir().unwrap(); @@ -143,7 +166,10 @@ mod tests { let (_, suppressed2) = append_routed(None, "high", &[], "the exact same af-guard bug").unwrap(); assert!(!suppressed1, "first occurrence must be captured"); - assert!(suppressed2, "second identical vent within the window must be throttled"); + assert!( + suppressed2, + "second identical vent within the window must be throttled" + ); }); } @@ -166,12 +192,18 @@ mod tests { #[test] fn append_routed_sends_agentflare_core_and_external_to_different_logs() { crate::paths::test_support::with_temp_home(|| { - append_routed(None, "high", &[], "af-guard blocked a protected branch push").unwrap(); + append_routed( + None, + "high", + &[], + "af-guard blocked a protected branch push", + ) + .unwrap(); append_routed(None, "high", &[], "a totally unrelated bash quoting bug").unwrap(); - let global_text = std::fs::read_to_string(crate::vent::paths::global_log_path()) - .unwrap_or_default(); - let repo_text = std::fs::read_to_string(crate::vent::paths::log_path()) - .unwrap_or_default(); + let global_text = + std::fs::read_to_string(crate::vent::paths::global_log_path()).unwrap_or_default(); + let repo_text = + std::fs::read_to_string(crate::vent::paths::log_path()).unwrap_or_default(); assert!(global_text.contains("af-guard")); assert!(!repo_text.contains("af-guard")); assert!(repo_text.contains("bash quoting bug")); diff --git a/src/vent/file.rs b/src/vent/file.rs index 69789c123..7385118fc 100644 --- a/src/vent/file.rs +++ b/src/vent/file.rs @@ -38,11 +38,15 @@ fn load_filed(filed_path: &Path) -> BTreeMap { serde_json::from_str(&text).unwrap_or_default() } +/// Writes via a sibling temp file + rename so a process killed mid-write +/// can never leave `filed_path` holding a truncated/corrupt JSON document. fn save_filed(filed_path: &Path, filed: &BTreeMap) -> std::io::Result<()> { if let Some(parent) = filed_path.parent() { std::fs::create_dir_all(parent)?; } - std::fs::write(filed_path, serde_json::to_string_pretty(filed)?) + let tmp_path = filed_path.with_extension("json.tmp"); + std::fs::write(&tmp_path, serde_json::to_string_pretty(filed)?)?; + std::fs::rename(&tmp_path, filed_path) } /// Groups the global agentflare-core log by topic, applies the same @@ -100,14 +104,38 @@ pub fn mark_filed( save_filed(filed_path, &filed) } +const GH_ISSUE_CREATE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + /// Shells out to `gh issue create`. Not unit tested (matches this repo's existing /// convention for subprocess calls) — exercised at the integration/manual level. +/// Bounded by `GH_ISSUE_CREATE_TIMEOUT` so a hung `gh` (network stall, stuck +/// auth prompt) can't block the calling MCP tool call/CLI invocation forever. pub fn create_github_issue(title: &str, body: &str) -> std::io::Result { - let output = std::process::Command::new("gh") - .args([ - "issue", "create", "--repo", "getappz/agentflare", "--title", title, "--body", body, - ]) - .output()?; + let title = title.to_string(); + let body = body.to_string(); + let output = crate::ipc::process::run_with_timeout( + move || { + std::process::Command::new("gh") + .args([ + "issue", + "create", + "--repo", + "getappz/agentflare", + "--title", + &title, + "--body", + &body, + ]) + .output() + }, + GH_ISSUE_CREATE_TIMEOUT, + ) + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("gh issue create did not finish within {GH_ISSUE_CREATE_TIMEOUT:?}"), + ) + })??; if !output.status.success() { return Err(std::io::Error::other( String::from_utf8_lossy(&output.stderr).to_string(), @@ -155,7 +183,10 @@ mod tests { ); let batch = pending_batch(&log, &filed, 0); assert_eq!(batch.len(), 2, "two distinct topics"); - let guard_group = batch.iter().find(|g| g.message.contains("af-guard")).unwrap(); + let guard_group = batch + .iter() + .find(|g| g.message.contains("af-guard")) + .unwrap(); assert_eq!(guard_group.seen_count, 2); } @@ -176,7 +207,13 @@ mod tests { let batch = pending_batch(&log, &filed, now); assert_eq!(batch.len(), 1); let keys: Vec = batch.iter().map(|g| g.topic_key.clone()).collect(); - mark_filed(&filed, &keys, "https://github.com/getappz/agentflare/issues/1", now).unwrap(); + mark_filed( + &filed, + &keys, + "https://github.com/getappz/agentflare/issues/1", + now, + ) + .unwrap(); assert!( pending_batch(&log, &filed, now + 60).is_empty(), "already-filed topic must not reappear within the TTL window"