Skip to main content

orch/channels/
capture.rs

1//! Capture service — polls tmux sessions for output and broadcasts to transport.
2//!
3//! This service:
4//! - Runs a background loop every 2 seconds
5//! - Captures pane output for registered sessions
6//! - Diffs against previous capture to find new content
7//! - Pushes new content as OutputChunk to transport
8//!
9//! Sessions are registered when tasks are dispatched and unregistered
10//! when they complete.
11
12use crate::channels::tmux;
13use crate::channels::transport::{session_key, Transport};
14use crate::channels::OutputChunk;
15use chrono::{DateTime, Utc};
16use sha2::{Digest, Sha256};
17use std::collections::HashMap;
18use std::sync::Arc;
19use tokio::sync::RwLock;
20
21pub(crate) const MAX_OUTPUT_BUFFER_BYTES: usize = 1024 * 1024;
22
23/// Buffer for tracking session output state.
24#[derive(Debug, Clone)]
25pub struct OutputBuffer {
26    /// Repo slug (owner/repo) that owns this session.
27    pub repo: String,
28    /// The tmux session name (e.g., "orch-myproject-42")
29    pub session: String,
30    /// The task ID this session belongs to
31    pub task_id: String,
32    /// Content from the last capture
33    pub last_content: String,
34    /// Byte length of the last capture
35    pub last_len: usize,
36    /// Hash of the last capture (for dedup)
37    pub last_hash: Option<[u8; 32]>,
38    /// When the last capture occurred
39    pub last_capture: DateTime<Utc>,
40    /// Whether the session has been seen alive at least once (i.e., `capture_pane`
41    /// succeeded at least once). Prevents firing "session ended" for sessions that
42    /// were registered before the tmux session was actually created.
43    /// Also used by silence detection: a seen-alive session with no output is only
44    /// treated as silent if the tmux session is currently dead (silent exit-0).
45    pub seen_alive: bool,
46    /// When this session was registered for capture.
47    pub registered_at: DateTime<Utc>,
48    /// Whether the agent has produced any meaningful output since session start.
49    /// Used for silence detection: only agents that never produced output are
50    /// considered silent. Agents that produced output then go quiet are NOT
51    /// killed (they may be running long tool calls with sparse output).
52    pub has_output: bool,
53    /// Generation counter incremented on each registration for this session_key.
54    /// Used to detect when a session has been re-registered (e.g., after retry)
55    /// so that stale snapshots from previous generations don't incorrectly
56    /// trigger final/unregister side effects.
57    pub generation: u64,
58}
59
60/// Service that captures tmux pane output and broadcasts to transport.
61pub struct CaptureService {
62    /// Session buffers keyed by session_key(repo, task_id)
63    buffers: Arc<RwLock<HashMap<String, OutputBuffer>>>,
64    /// Transport layer for broadcasting output
65    transport: Arc<Transport>,
66    /// Polling interval
67    interval: std::time::Duration,
68}
69
70impl CaptureService {
71    /// Create a new CaptureService.
72    pub fn new(transport: Arc<Transport>) -> Self {
73        Self {
74            buffers: Arc::new(RwLock::new(HashMap::new())),
75            transport,
76            interval: std::time::Duration::from_secs(2),
77        }
78    }
79
80    /// Register a session to be tracked.
81    pub async fn register_session(&self, repo: &str, task_id: &str, session: &str) {
82        let now = Utc::now();
83        let skey = session_key(repo, task_id);
84
85        // Atomically read the current generation and insert the new buffer under
86        // a single write lock to prevent a TOCTOU race where two concurrent
87        // re-registrations both observe the same stale generation value.
88        let generation = {
89            let mut buffers = self.buffers.write().await;
90            let generation = buffers.get(&skey).map(|b| b.generation + 1).unwrap_or(0);
91            let buffer = OutputBuffer {
92                repo: repo.to_string(),
93                session: session.to_string(),
94                task_id: task_id.to_string(),
95                last_content: String::new(),
96                last_len: 0,
97                last_hash: None,
98                last_capture: now,
99                seen_alive: false,
100                registered_at: now,
101                has_output: false,
102                generation,
103            };
104            buffers.insert(skey, buffer);
105            generation
106        };
107        tracing::debug!(
108            repo,
109            task_id,
110            session,
111            generation,
112            "session registered for capture"
113        );
114    }
115
116    /// Unregister a session (stop tracking).
117    pub async fn unregister_session(&self, repo: &str, task_id: &str) {
118        let skey = session_key(repo, task_id);
119        if let Some(buffer) = self.buffers.write().await.remove(&skey) {
120            tracing::debug!(
121                repo = buffer.repo,
122                task_id = buffer.task_id,
123                session = buffer.session,
124                "session unregistered"
125            );
126        }
127        self.transport.unbind(repo, task_id).await;
128    }
129
130    /// Start the capture loop.
131    ///
132    /// This runs indefinitely, polling registered sessions for new output.
133    pub async fn start(&self) {
134        tracing::info!(
135            interval_secs = self.interval.as_secs(),
136            "capture service started"
137        );
138        let mut interval = tokio::time::interval(self.interval);
139
140        loop {
141            interval.tick().await;
142            self.tick().await;
143        }
144    }
145
146    /// Run the capture loop while there are registered sessions.
147    ///
148    /// This returns when no more sessions are registered, making it suitable
149    /// for CLI streaming where the capture should stop when the session ends.
150    pub async fn run(&self) {
151        let mut interval = tokio::time::interval(self.interval);
152
153        loop {
154            interval.tick().await;
155
156            // Check if there are any sessions to capture
157            let has_sessions = !self.buffers.read().await.is_empty();
158            if !has_sessions {
159                tracing::debug!("no sessions registered, capture loop exiting");
160                break;
161            }
162
163            self.tick().await;
164        }
165    }
166
167    /// Return task IDs of sessions that have been silent since registration.
168    ///
169    /// A session is "silent" when:
170    /// 1. It has been registered for longer than `grace_period`
171    /// 2. It has NEVER produced any meaningful output (`has_output == false`)
172    /// 3. Either it was NEVER confirmed alive, OR it was seen alive but the tmux
173    ///    session is now dead (silent exit-0 case)
174    ///
175    /// Condition (3) balances two cases:
176    /// - Session alive and working (tmux session exists, agent running, sparse output)
177    ///   → should NOT be killed; hard timeout will catch it if it runs too long (#2318)
178    /// - Session was alive but exited silently with no output (tmux session dead)
179    ///   → SHOULD trigger silence fallback within grace_period, not wait 30 min (#2573)
180    ///
181    /// Returns (task_id, session_name, age_secs) for silent sessions.
182    pub async fn get_silent_sessions_for_repo(
183        &self,
184        repo: &str,
185        grace_period: std::time::Duration,
186    ) -> Vec<(String, String, i64)> {
187        let now = Utc::now();
188        let buffers = self.buffers.read().await;
189        let mut candidates: Vec<OutputBuffer> = buffers
190            .values()
191            .filter(|buf| buf.repo == repo && !buf.has_output)
192            .cloned()
193            .collect();
194        drop(buffers);
195
196        let mut silent = Vec::new();
197        for buf in candidates.drain(..) {
198            if buf.seen_alive {
199                // Session was confirmed alive at least once. If the tmux session is
200                // still running, it's doing real work with sparse output — skip it
201                // (the hard stuck_timeout will catch runaway tasks). If the session
202                // is dead and produced no output, it's a silent exit — fall through
203                // to the grace period check so silence detection fires within 120s
204                // instead of waiting the full 30-minute stuck_timeout.
205                if !tmux::is_session_dead(&buf.session).await {
206                    continue;
207                }
208                tracing::debug!(
209                    task_id = buf.task_id,
210                    session = buf.session,
211                    "session seen-alive but now dead with no output, treating as silent exit"
212                );
213            }
214
215            // Re-check the current generation to avoid silencing sessions that were
216            // re-registered (e.g., after a retry/dispatch). If the generation has
217            // changed, the session was restarted and the old entry is stale.
218            let skey = session_key(&buf.repo, &buf.task_id);
219            let current_generation = {
220                let buffers = self.buffers.read().await;
221                buffers.get(&skey).map(|b| b.generation)
222            };
223            if current_generation != Some(buf.generation) {
224                tracing::debug!(
225                    task_id = buf.task_id,
226                    session = buf.session,
227                    old_gen = buf.generation,
228                    new_gen = current_generation,
229                    "session generation changed, skipping silence detection (was re-registered)"
230                );
231                continue;
232            }
233
234            let raw_age = now.signed_duration_since(buf.registered_at);
235            // Host suspend (laptop sleep) freezes the process just like it freezes
236            // the agent running inside it — suspended wall-clock time is not evidence
237            // of a hung agent. Discount any detected suspend/resume gaps that occurred
238            // since the session was registered, mirroring the watchdog fix in
239            // engine::tick (`stuck_task_timing_from_map`).
240            let suspended = crate::engine::suspend::suspended_duration_since(buf.registered_at);
241            let age = (raw_age - suspended).max(chrono::Duration::zero());
242            if age.num_seconds() > grace_period.as_secs() as i64 {
243                silent.push((buf.task_id.clone(), buf.session.clone(), age.num_seconds()));
244            }
245        }
246        silent
247    }
248
249    /// Run one tick of the capture loop.
250    ///
251    /// Acquires the read lock once and iterates over all buffers to avoid
252    /// the race condition of collecting keys, dropping the lock, then
253    /// re-acquiring to look up each buffer (which could result in missing
254    /// or stale entries).
255    async fn tick(&self) {
256        let buffers = self.buffers.read().await;
257        let sessions: Vec<(String, OutputBuffer)> = buffers
258            .iter()
259            .map(|(k, v)| (k.clone(), v.clone()))
260            .collect();
261        drop(buffers);
262
263        for (skey, buffer) in sessions {
264            // Capture pane content directly via tmux
265            let current_content = match tmux::capture_pane(&buffer.session).await {
266                Ok(s) => s,
267                Err(e) => {
268                    // Validate current buffer still represents same session instance
269                    // before triggering final/unregister side effects. This prevents
270                    // stale snapshots from previous registration generations
271                    // (e.g., after retry/re-dispatch) from incorrectly terminating
272                    // a newly registered session.
273                    let current = {
274                        let buffers = self.buffers.read().await;
275                        buffers.get(&skey).cloned()
276                    };
277
278                    // Only fire "session ended" if:
279                    // 1. The session still exists in the map
280                    // 2. It has the same generation (not re-registered)
281                    // 3. It was seen alive before
282                    // 4. The tmux session is actually dead
283                    let should_finalize = current
284                        .as_ref()
285                        .map(|c| {
286                            c.generation == buffer.generation
287                                && c.seen_alive
288                                && c.session == buffer.session
289                        })
290                        .unwrap_or(false);
291
292                    if should_finalize && tmux::is_session_dead(&buffer.session).await {
293                        tracing::info!(
294                            task_id = buffer.task_id,
295                            session = buffer.session,
296                            generation = buffer.generation,
297                            "session ended, sending final chunk"
298                        );
299                        let chunk = OutputChunk {
300                            content: String::new(),
301                            is_final: true,
302                        };
303                        self.transport
304                            .push_output(&buffer.repo, &buffer.task_id, chunk)
305                            .await;
306                        self.unregister_session(&buffer.repo, &buffer.task_id).await;
307                    } else if current.is_none() {
308                        tracing::trace!(
309                            task_id = buffer.task_id,
310                            session = buffer.session,
311                            "session already unregistered"
312                        );
313                    } else {
314                        tracing::trace!(
315                            task_id = buffer.task_id,
316                            session = buffer.session,
317                            ?e,
318                            "capture failed (transient)"
319                        );
320                    }
321                    continue;
322                }
323            };
324
325            let new_content = {
326                let mut buffers = self.buffers.write().await;
327                if let Some(buf) = buffers.get_mut(&skey) {
328                    buf.seen_alive = true;
329                    buf.diff_and_update(&current_content)
330                } else {
331                    None
332                }
333            };
334
335            if let Some(new_content) = new_content {
336                let chunk = OutputChunk {
337                    content: new_content,
338                    is_final: false,
339                };
340                self.transport
341                    .push_output(&buffer.repo, &buffer.task_id, chunk)
342                    .await;
343            }
344        }
345    }
346}
347
348impl OutputBuffer {
349    pub(crate) fn diff_and_update(&mut self, current_content: &str) -> Option<String> {
350        let current_bytes = current_content.as_bytes();
351        let current_len = current_bytes.len();
352        let current_hash = hash_bytes(current_bytes);
353
354        if self.last_len == current_len && self.last_hash == Some(current_hash) {
355            self.last_capture = Utc::now();
356            return None;
357        }
358
359        let new_content = if self.last_len > current_len {
360            // Content shrank (terminal cleared) — no incremental diff available.
361            // Emitting full current_content would duplicate already-broadcast output.
362            String::new()
363        } else if self.last_len == current_len {
364            // Same-length overwrite (e.g. spinner/progress refresh).
365            // Find the longest common prefix and return only the changed suffix.
366            let common_prefix = self
367                .last_content
368                .as_bytes()
369                .iter()
370                .zip(current_content.as_bytes())
371                .take_while(|(a, b)| a == b)
372                .count();
373            let mut start = common_prefix;
374            while start < current_len && !current_content.is_char_boundary(start) {
375                start += 1;
376            }
377            current_content[start..].to_string()
378        } else {
379            let mut offset = self.last_len;
380            while offset < current_len && !current_content.is_char_boundary(offset) {
381                offset += 1;
382            }
383            String::from_utf8_lossy(&current_bytes[offset..]).to_string()
384        };
385
386        self.last_len = current_len;
387        self.last_hash = Some(current_hash);
388        self.last_content = cap_content(current_content);
389        self.last_capture = Utc::now();
390
391        if new_content.trim().is_empty() {
392            None
393        } else {
394            self.has_output = true;
395            Some(new_content)
396        }
397    }
398}
399
400fn hash_bytes(bytes: &[u8]) -> [u8; 32] {
401    let mut hasher = Sha256::new();
402    hasher.update(bytes);
403    hasher.finalize().into()
404}
405
406fn cap_content(content: &str) -> String {
407    if content.len() <= MAX_OUTPUT_BUFFER_BYTES {
408        return content.to_string();
409    }
410
411    let mut start = content.len() - MAX_OUTPUT_BUFFER_BYTES;
412    while start < content.len() && !content.is_char_boundary(start) {
413        start += 1;
414    }
415    content[start..].to_string()
416}
417
418impl CaptureService {
419    /// Test helper: mutate buffer fields for a registered session by task_id.
420    /// Panics if the session is not registered.
421    #[cfg(test)]
422    pub(crate) async fn set_buffer_state_for_test(
423        &self,
424        repo: &str,
425        task_id: &str,
426        seen_alive: bool,
427        has_output: bool,
428        registered_at: chrono::DateTime<Utc>,
429    ) {
430        let skey = crate::channels::transport::session_key(repo, task_id);
431        let mut buffers = self.buffers.write().await;
432        let buf = buffers
433            .get_mut(&skey)
434            .unwrap_or_else(|| panic!("no buffer for {repo}/{task_id}"));
435        buf.seen_alive = seen_alive;
436        buf.has_output = has_output;
437        buf.registered_at = registered_at;
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use crate::channels::transport::Transport;
445
446    fn make_buffer() -> OutputBuffer {
447        OutputBuffer {
448            repo: "owner/repo".to_string(),
449            session: "test-session".to_string(),
450            task_id: "task-1".to_string(),
451            last_content: String::new(),
452            last_len: 0,
453            last_hash: None,
454            last_capture: Utc::now(),
455            seen_alive: false,
456            registered_at: Utc::now(),
457            has_output: false,
458            generation: 0,
459        }
460    }
461
462    #[test]
463    fn normal_append_returns_new_suffix() {
464        let mut buf = make_buffer();
465        // First capture
466        let result = buf.diff_and_update("hello");
467        assert_eq!(result.as_deref(), Some("hello"));
468        // Append more
469        let result = buf.diff_and_update("hello world");
470        assert_eq!(result.as_deref(), Some(" world"));
471    }
472
473    #[test]
474    fn no_change_returns_none() {
475        let mut buf = make_buffer();
476        buf.diff_and_update("hello");
477        // Same content → no diff
478        let result = buf.diff_and_update("hello");
479        assert_eq!(result, None);
480    }
481
482    #[test]
483    fn terminal_clear_shrink_returns_none_not_full_content() {
484        let mut buf = make_buffer();
485        // Simulate some prior output
486        buf.diff_and_update("line1\nline2\nline3\n");
487        // Terminal clears: pane now contains less content (e.g. after \033[2J)
488        let result = buf.diff_and_update("prompt$ ");
489        // Must NOT return the full current content as "new" — that would duplicate output.
490        assert_eq!(
491            result, None,
492            "shrinking pane content should produce no output, not a duplicate of visible screen"
493        );
494    }
495
496    #[test]
497    fn terminal_clear_to_empty_returns_none() {
498        let mut buf = make_buffer();
499        buf.diff_and_update("lots of output here\nmore output\n");
500        // Pane cleared to empty
501        let result = buf.diff_and_update("");
502        assert_eq!(result, None);
503    }
504
505    #[test]
506    fn same_length_content_change_is_broadcast() {
507        let mut buf = make_buffer();
508        buf.diff_and_update("aaaa");
509
510        let result = buf.diff_and_update("bbbb");
511
512        assert_eq!(result.as_deref(), Some("bbbb"));
513    }
514
515    #[test]
516    fn same_length_partial_change_broadcasts_only_changed_suffix() {
517        let mut buf = make_buffer();
518        // Simulate 200 lines of prior output followed by a spinner character.
519        let prior = format!("{}/", "line\n".repeat(50));
520        buf.diff_and_update(&prior);
521
522        // Spinner ticks: only last char changes, everything before is identical.
523        let updated = format!("{}-", "line\n".repeat(50));
524        assert_eq!(
525            prior.len(),
526            updated.len(),
527            "lengths must match for this test"
528        );
529
530        let result = buf.diff_and_update(&updated);
531
532        // Only the changed suffix ("-") should be returned, not the full content.
533        assert_eq!(
534            result.as_deref(),
535            Some("-"),
536            "should return only the changed suffix, not the full content"
537        );
538    }
539
540    #[test]
541    fn empty_initial_capture_returns_none() {
542        let mut buf = make_buffer();
543        let result = buf.diff_and_update("");
544        assert_eq!(result, None);
545    }
546
547    #[test]
548    fn has_output_set_on_meaningful_content() {
549        let mut buf = make_buffer();
550        assert!(!buf.has_output);
551        buf.diff_and_update("hello");
552        assert!(buf.has_output);
553    }
554
555    #[test]
556    fn has_output_not_set_on_empty_content() {
557        let mut buf = make_buffer();
558        buf.diff_and_update("");
559        assert!(!buf.has_output, "empty content should not set has_output");
560    }
561
562    #[test]
563    fn has_output_stays_true_after_silence() {
564        let mut buf = make_buffer();
565        buf.diff_and_update("hello");
566        assert!(buf.has_output);
567        // Same content (no new output) — has_output should remain true
568        buf.diff_and_update("hello");
569        assert!(buf.has_output, "has_output should not revert once set");
570    }
571
572    #[tokio::test]
573    async fn get_silent_sessions_returns_only_no_output_past_grace() {
574        let transport = Arc::new(Transport::new());
575        let svc = CaptureService::new(transport);
576        let repo = "owner/repo";
577
578        // Register a session and backdate it past the grace period.
579        // seen_alive=false (default) + has_output=false → IS silent.
580        svc.register_session(repo, "silent-task", "orch-test-silent")
581            .await;
582        svc.set_buffer_state_for_test(
583            repo,
584            "silent-task",
585            false,
586            false,
587            Utc::now() - chrono::Duration::seconds(200),
588        )
589        .await;
590
591        // Register a session that HAS produced output (should not be returned).
592        svc.register_session(repo, "active-task", "orch-test-active")
593            .await;
594        svc.set_buffer_state_for_test(
595            repo,
596            "active-task",
597            false,
598            true, // has_output
599            Utc::now() - chrono::Duration::seconds(200),
600        )
601        .await;
602
603        // Register a fresh session within grace period (should not be returned).
604        svc.register_session(repo, "new-task", "orch-test-new")
605            .await;
606
607        let grace = std::time::Duration::from_secs(120);
608        let silent = svc.get_silent_sessions_for_repo(repo, grace).await;
609        assert_eq!(silent.len(), 1);
610        assert_eq!(silent[0].0, "silent-task");
611    }
612
613    /// Regression test for issue #2318 and fix for issue #2573.
614    ///
615    /// A session confirmed alive (capture_pane succeeded at least once) with no
616    /// terminal output falls into two cases:
617    ///
618    /// - **Tmux session dead, no output** (silent exit-0, issue #2573): IS silent.
619    ///   The agent exited immediately without printing anything. Should be detected
620    ///   within the grace period, not wait 30 minutes for stuck_timeout.
621    ///
622    /// - **Tmux session alive, no output** (issue #2318): NOT silent.
623    ///   Claude agents doing complex refactoring can run 15+ minutes with sparse
624    ///   terminal output (writing files via Edit/Write tools). Hard timeout handles them.
625    ///
626    /// In tests, tmux sessions don't exist, so `is_session_dead` returns `true` for
627    /// both sessions below — both "never-seen" and "seen-but-dead" are correctly
628    /// classified as silent exits.
629    #[tokio::test]
630    async fn get_silent_sessions_seen_alive_dead_session_is_silent() {
631        let transport = Arc::new(Transport::new());
632        let svc = CaptureService::new(transport);
633        let repo = "owner/repo";
634
635        // Session past grace period, never seen alive → IS silent.
636        svc.register_session(repo, "never-seen-task", "orch-never-seen")
637            .await;
638        svc.set_buffer_state_for_test(
639            repo,
640            "never-seen-task",
641            false, // seen_alive = false (default)
642            false, // has_output = false
643            Utc::now() - chrono::Duration::seconds(500),
644        )
645        .await;
646
647        // Session past grace period, seen alive, no output, tmux session dead
648        // (simulates silent exit-0 from issue #2573) → IS silent.
649        // The session name ends with "-dead" to work with both the integration test
650        // mock (which returns `is_session_dead=true` for "-dead" names) and the unit
651        // test context (where no real tmux session by that name exists → dead).
652        svc.register_session(repo, "seen-but-dead-task", "orch-seen-dead")
653            .await;
654        svc.set_buffer_state_for_test(
655            repo,
656            "seen-but-dead-task",
657            true,  // seen_alive: capture_pane succeeded at least once
658            false, // has_output = false, tmux session no longer exists → silent exit
659            Utc::now() - chrono::Duration::seconds(500),
660        )
661        .await;
662
663        let grace = std::time::Duration::from_secs(120);
664        let silent = svc.get_silent_sessions_for_repo(repo, grace).await;
665        // Both sessions should be returned: "never-seen-task" was never alive, and
666        // "seen-but-dead-task" was alive but the tmux session is now dead with no output.
667        assert_eq!(
668            silent.len(),
669            2,
670            "both never-alive and dead-with-no-output sessions must be silenced"
671        );
672        let ids: std::collections::HashSet<&str> =
673            silent.iter().map(|(id, _, _)| id.as_str()).collect();
674        assert!(ids.contains("never-seen-task"));
675        assert!(ids.contains("seen-but-dead-task"));
676    }
677
678    #[tokio::test]
679    async fn get_silent_sessions_filters_by_repo() {
680        let transport = Arc::new(Transport::new());
681        let svc = CaptureService::new(transport);
682
683        svc.register_session("owner/repo-a", "task-a", "orch-a")
684            .await;
685        svc.register_session("owner/repo-b", "task-b", "orch-b")
686            .await;
687        svc.set_buffer_state_for_test(
688            "owner/repo-a",
689            "task-a",
690            false,
691            false,
692            Utc::now() - chrono::Duration::seconds(200),
693        )
694        .await;
695        svc.set_buffer_state_for_test(
696            "owner/repo-b",
697            "task-b",
698            false,
699            false,
700            Utc::now() - chrono::Duration::seconds(200),
701        )
702        .await;
703
704        let grace = std::time::Duration::from_secs(120);
705        let silent_a = svc
706            .get_silent_sessions_for_repo("owner/repo-a", grace)
707            .await;
708        assert_eq!(silent_a.len(), 1);
709        assert_eq!(silent_a[0].0, "task-a");
710
711        let silent_b = svc
712            .get_silent_sessions_for_repo("owner/repo-b", grace)
713            .await;
714        assert_eq!(silent_b.len(), 1);
715        assert_eq!(silent_b[0].0, "task-b");
716    }
717
718    /// Same external task ID in two repos must not collide in capture buffers.
719    #[tokio::test]
720    async fn same_task_id_different_repos_capture_buffers_isolated() {
721        let transport = Arc::new(Transport::new());
722        let svc = CaptureService::new(transport);
723
724        svc.register_session("owner/repo-a", "42", "orch-a-42")
725            .await;
726        svc.register_session("owner/repo-b", "42", "orch-b-42")
727            .await;
728
729        let skey_a = session_key("owner/repo-a", "42");
730        let skey_b = session_key("owner/repo-b", "42");
731
732        let buffers = svc.buffers.read().await;
733        let buf_a = buffers
734            .get(&skey_a)
735            .expect("buffer should exist for repo-a session");
736        let buf_b = buffers
737            .get(&skey_b)
738            .expect("buffer should exist for repo-b session");
739
740        assert_eq!(buf_a.session, "orch-a-42");
741        assert_eq!(buf_b.session, "orch-b-42");
742        assert_ne!(buf_a.session, buf_b.session);
743        assert_eq!(buf_a.repo, "owner/repo-a");
744        assert_eq!(buf_b.repo, "owner/repo-b");
745    }
746
747    #[test]
748    fn multibyte_boundary_no_bytes_dropped() {
749        // "Hello " is 6 bytes. Append a 3-byte CJK character (世, U+4E16).
750        // Simulate last_len landing in the middle of the 3-byte sequence.
751        let mut buf = make_buffer();
752        buf.diff_and_update("Hello ");
753        // Full string with CJK appended
754        let full = "Hello 世界";
755        let result = buf.diff_and_update(full);
756        // The new suffix must contain both CJK characters intact.
757        let s = result.expect("should return new content");
758        assert!(s.contains('世'), "first CJK char must not be dropped");
759        assert!(s.contains('界'), "second CJK char must not be dropped");
760    }
761
762    #[test]
763    fn whitespace_only_new_content_returns_none() {
764        let mut buf = make_buffer();
765        buf.diff_and_update("hello");
766        // New suffix is only whitespace
767        let result = buf.diff_and_update("hello   \n");
768        assert_eq!(result, None);
769    }
770
771    /// Regression test for issue #2932.
772    ///
773    /// When a session is re-registered (e.g., after retry/dispatch), the old
774    /// buffer entry has a stale generation. Silence detection must not kill the
775    /// NEW session just because an older entry with the same task_id was registered
776    /// before the grace period elapsed. The re-registered session should be
777    /// treated as fresh — it has a higher generation and different session name.
778    #[tokio::test]
779    async fn get_silent_sessions_skips_stale_generation() {
780        let transport = Arc::new(Transport::new());
781        let svc = CaptureService::new(transport);
782        let repo = "owner/repo";
783
784        // Register task once with a session, backdate it past grace period.
785        svc.register_session(repo, "task-42", "orch-old-session")
786            .await;
787        svc.set_buffer_state_for_test(
788            repo,
789            "task-42",
790            false,
791            false,
792            Utc::now() - chrono::Duration::seconds(500),
793        )
794        .await;
795
796        // Re-register the same task with a new session (generation incremented).
797        svc.register_session(repo, "task-42", "orch-new-session")
798            .await;
799
800        let grace = std::time::Duration::from_secs(120);
801        let silent = svc.get_silent_sessions_for_repo(repo, grace).await;
802
803        // The stale old-generation entry must be skipped.
804        // The re-registered session has generation=1 and is fresh (< 120s old),
805        // so it must NOT appear in the silent list.
806        assert!(
807            silent.is_empty(),
808            "re-registered session should not be silenced; stale entry should be skipped"
809        );
810    }
811}