Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,15 @@ pub struct TmuxSessionMonitor {
pub channel: Option<String>,
pub mention: Option<String>,
pub format: Option<MessageFormat>,
#[serde(default)]
pub detect_waiting: bool,
/// Cooldown minutes between waiting-for-input alerts. 0 = no cooldown.
#[serde(default)]
pub waiting_interval: u64,
/// Event kinds that trigger the @mention. Empty = mention applies to all events.
/// Valid values: "keyword", "waiting_for_input", "content_changed", "stale", "heartbeat".
#[serde(default)]
pub mention_on: Vec<String>,
}

impl Default for TmuxSessionMonitor {
Expand All @@ -299,6 +308,9 @@ impl Default for TmuxSessionMonitor {
channel: None,
mention: None,
format: None,
detect_waiting: false,
waiting_interval: 0,
mention_on: Vec::new(),
}
}
}
Expand Down
1 change: 1 addition & 0 deletions src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,7 @@ mod tests {
name: Some("codex".into()),
}),
active_wrapper_monitor: true,
..Default::default()
},
);
let state = AppState {
Expand Down
16 changes: 15 additions & 1 deletion src/discord.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,9 @@ impl DiscordClient {
return Ok(());
}
Err(error) => {
self.record_failure(&key);
if is_infrastructure_failure(&error) {
self.record_failure(&key);
}
if let Some(retry_after) = error.retry_after
&& attempt < MAX_ATTEMPTS
{
Expand Down Expand Up @@ -270,6 +272,18 @@ impl DiscordClient {
}
}

/// Returns true only for infrastructure failures (5xx, network/connection errors).
/// Discord API policy rejections (4xx: 404, 400/30046, 401, 429) are NOT infrastructure
/// failures and should not open the circuit breaker — they are handled at each call site.
fn is_infrastructure_failure(error: &DiscordSendError) -> bool {
let m = &error.message;
m.contains("500 ")
|| m.contains("502 ")
|| m.contains("503 ")
|| m.contains("504 ")
|| (m.contains("failed:") && !m.contains("failed with"))
}

fn parse_retry_after(status: StatusCode, body: &str) -> Option<Duration> {
if status != StatusCode::TOO_MANY_REQUESTS {
return None;
Expand Down
1 change: 1 addition & 0 deletions src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,7 @@ fn should_bypass_routine_batch(event: &IncomingEvent) -> bool {
kind.ends_with(".failed")
|| kind.ends_with(".blocked")
|| kind == "tmux.stale"
|| kind == "tmux.session_ended"
|| kind.starts_with("github.ci-")
}

Expand Down
21 changes: 21 additions & 0 deletions src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,27 @@ impl IncomingEvent {
}
}

/// Session is waiting for user input.
pub fn tmux_waiting_for_input(
session: String,
pane_name: String,
prompt_snapshot: String,
channel: Option<String>,
) -> Self {
Self {
kind: "tmux.waiting_for_input".to_string(),
channel,
mention: None,
format: None,
template: None,
payload: json!({
"session": session,
"pane": pane_name,
"prompt_snapshot": prompt_snapshot,
}),
}
}

pub fn with_mention(mut self, mention: Option<String>) -> Self {
self.mention = mention;
self
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,7 @@ mod tests {
name: Some("codex".into()),
}),
active_wrapper_monitor: true,
..Default::default()
}]);

assert!(output.contains(
Expand Down
42 changes: 42 additions & 0 deletions src/render/default.rs
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,26 @@ impl Renderer for DefaultRenderer {
),
("tmux.stale", MessageFormat::Raw) => serde_json::to_string_pretty(payload)?,

("tmux.waiting_for_input", MessageFormat::Compact) => {
let session = string_field(payload, "session")?;
let prompt = optional_string_field(payload, "prompt_snapshot")
.unwrap_or_else(|| "no prompt".to_string());
format!("⏳ **{session}**:\n```\n{prompt}\n```")
}
("tmux.waiting_for_input", MessageFormat::Alert) => {
let session = string_field(payload, "session")?;
let prompt = optional_string_field(payload, "prompt_snapshot")
.unwrap_or_else(|| "no prompt".to_string());
format!("⏳ **{session}**:\n```\n{prompt}\n```")
}
("tmux.waiting_for_input", MessageFormat::Inline) => {
let session = string_field(payload, "session")?;
format!("⏳ [{session}]")
}
("tmux.waiting_for_input", MessageFormat::Raw) => {
serde_json::to_string_pretty(payload)?
}

(_, MessageFormat::Raw) => serde_json::to_string_pretty(payload)?,
(_, _) => serde_json::to_string(payload)?,
};
Expand Down Expand Up @@ -965,4 +985,26 @@ mod tests {
assert!(rendered.starts_with("🚨"));
assert!(rendered.contains("release published"));
}

#[test]
fn renders_tmux_waiting_for_input_formats() {
let renderer = DefaultRenderer;
let event = IncomingEvent::tmux_waiting_for_input(
"issue-24".into(),
"0.1".into(),
"Press enter to continue".into(),
None,
);

assert_eq!(
renderer.render(&event, &MessageFormat::Inline).unwrap(),
"⏳ [issue-24]"
);
assert!(
renderer
.render(&event, &MessageFormat::Compact)
.unwrap()
.starts_with("⏳ **issue-24**")
);
}
}
3 changes: 3 additions & 0 deletions src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,9 @@ fn route_candidates(kind: &str) -> Vec<&str> {
| "session.handoff-needed" => {
vec![kind, "session.*"]
}
"tmux.waiting_for_input" => {
vec![kind, "tmux.*"]
}
other => vec![other],
}
}
Expand Down
Loading