Skip to main content

dk_engine/workspace/
session_manager.rs

1//! WorkspaceManager — manages all active session workspaces.
2//!
3//! Provides creation, lookup, destruction, and garbage collection of
4//! workspaces. Uses `DashMap` for lock-free concurrent access from
5//! multiple agent sessions.
6
7use std::sync::atomic::{AtomicU32, Ordering};
8use std::sync::Arc;
9
10use dashmap::DashMap;
11use dk_core::{AgentId, RepoId, Result};
12use serde::Serialize;
13use sqlx::PgPool;
14use tokio::time::Instant;
15use uuid::Uuid;
16
17use crate::workspace::cache::{NoOpCache, WorkspaceCache};
18use crate::workspace::session_workspace::{
19    SessionId, SessionWorkspace, WorkspaceMode,
20};
21
22// ── Feature flag helper ───────────────────────────────────────────────
23
24/// Returns `true` when `DKOD_PIN_NONTERMINAL` is enabled (default: on).
25///
26/// Opt out with `DKOD_PIN_NONTERMINAL=0` (also `false`, `off`, `no`).
27fn pin_flag_enabled() -> bool {
28    std::env::var("DKOD_PIN_NONTERMINAL")
29        .map(|v| {
30            let v = v.trim().to_ascii_lowercase();
31            !matches!(v.as_str(), "0" | "false" | "off" | "no")
32        })
33        .unwrap_or(true)
34}
35
36// ── EventPublisher ────────────────────────────────────────────────────
37
38/// Hook interface for emitting workspace lifecycle events.
39///
40/// Implemented by `dk-protocol` (forwarding to its event bus) in the live server;
41/// defaults to a no-op for pre-existing constructors and tests.
42pub trait EventPublisher: Send + Sync {
43    fn publish_session_event(
44        &self,
45        name: &str,
46        session_id: uuid::Uuid,
47        changeset_id: uuid::Uuid,
48        reason: &str,
49    );
50}
51
52/// No-op publisher used when callers don't wire an event bus.
53pub struct NoOpEventPublisher;
54
55impl EventPublisher for NoOpEventPublisher {
56    fn publish_session_event(&self, _: &str, _: uuid::Uuid, _: uuid::Uuid, _: &str) {}
57}
58
59// ── ResumeResult + ConflictingSymbol ─────────────────────────────────
60
61/// Outcome of [`WorkspaceManager::resume`].
62#[derive(Debug)]
63pub enum ResumeResult {
64    /// Workspace successfully rehydrated; caller can look up the new session
65    /// via [`WorkspaceManager::get_workspace`] using the returned [`SessionId`].
66    ///
67    /// `ResumeResult::Ok` holds `SessionId` rather than `SessionWorkspace`
68    /// because `SessionWorkspace` does not implement `Clone` (it owns a
69    /// `FileOverlay` backed by a live `PgPool` and a `DashMap`). Callers
70    /// use `WorkspaceManager::get_workspace(new_session_id)` to borrow the
71    /// resumed workspace.
72    Ok(SessionId),
73    /// One or more symbols are held by another active session.
74    /// Lock re-acquire is stubbed for Task 11; this variant is reserved for
75    /// when graph-based contention detection lands.
76    Contended(Vec<ConflictingSymbol>),
77    /// The workspace was already resumed by an earlier call; returns the
78    /// new session_id that superseded the dead one.
79    AlreadyResumed(SessionId),
80    /// The workspace was permanently abandoned and cannot be resumed.
81    Abandoned,
82    /// The session_id does not map to a stranded workspace (either missing
83    /// or already live with no `stranded_at`).
84    NotStranded,
85}
86
87/// A symbol held by a competing session that prevents lock re-acquire.
88#[derive(Debug, Clone)]
89pub struct ConflictingSymbol {
90    pub qualified_name: String,
91    pub file_path: String,
92    pub claimant_session: SessionId,
93    pub claimant_agent: String,
94}
95
96// ── StrandReason ─────────────────────────────────────────────────────
97
98/// Why a workspace transitioned to stranded.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum StrandReason {
101    IdleTtl,
102    CleanupDisconnected,
103    StartupReconcile,
104    Explicit,
105}
106
107impl StrandReason {
108    pub fn as_str(self) -> &'static str {
109        match self {
110            Self::IdleTtl => "idle_ttl",
111            Self::CleanupDisconnected => "cleanup_disconnected",
112            Self::StartupReconcile => "startup_reconcile",
113            Self::Explicit => "explicit",
114        }
115    }
116}
117
118// ── AbandonReason ────────────────────────────────────────────────────
119
120/// Why a workspace was permanently abandoned.
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub enum AbandonReason {
123    AutoTtl,
124    Explicit { caller: String },
125    Admin { operator: String },
126}
127
128impl AbandonReason {
129    pub fn as_str(&self) -> &'static str {
130        match self {
131            Self::AutoTtl => "auto_ttl",
132            Self::Explicit { .. } => "explicit",
133            Self::Admin { .. } => "admin",
134        }
135    }
136}
137
138// ── SessionInfo ─────────────────────────────────────────────────────
139
140/// Lightweight snapshot of a session workspace, suitable for JSON serialization.
141#[derive(Debug, Clone, Serialize)]
142pub struct SessionInfo {
143    pub session_id: Uuid,
144    pub agent_id: String,
145    pub agent_name: String,
146    pub intent: String,
147    pub repo_id: Uuid,
148    pub changeset_id: Uuid,
149    pub state: String,
150    pub elapsed_secs: u64,
151}
152
153// ── WorkspaceManager ─────────────────────────────────────────────────
154
155/// Minimum interval between L2 cache touch calls per session.
156const TOUCH_DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(30);
157
158/// Central registry of all active session workspaces.
159///
160/// Thread-safe via `DashMap`; every public method is either `&self` or
161/// returns a scoped reference guard.
162///
163/// The optional `cache` field holds an [`Arc`]-wrapped [`WorkspaceCache`]
164/// implementation. In single-pod deployments the default [`NoOpCache`] is
165/// used. Multi-pod deployments can supply a `ValkeyCache` (or any other
166/// implementation) via [`WorkspaceManager::with_cache`].
167pub struct WorkspaceManager {
168    workspaces: DashMap<SessionId, SessionWorkspace>,
169    agent_counters: DashMap<Uuid, AtomicU32>,
170    db: PgPool,
171    cache: Arc<dyn WorkspaceCache>,
172    /// Tracks when each session was last touched in L2 cache to debounce.
173    last_touched: DashMap<SessionId, Instant>,
174    claim_tracker: Arc<dyn crate::conflict::ClaimTracker>,
175    events: Arc<dyn EventPublisher>,
176}
177
178impl WorkspaceManager {
179    /// Create a new, empty workspace manager backed by [`NoOpCache`].
180    pub fn new(db: PgPool) -> Self {
181        Self::with_cache(db, Arc::new(NoOpCache))
182    }
183
184    /// Create a workspace manager with an explicit cache implementation.
185    ///
186    /// Use this constructor when a `ValkeyCache` or other L2 cache is
187    /// available. Pass `Arc::new(NoOpCache)` to opt-out of caching.
188    pub fn with_cache(db: PgPool, cache: Arc<dyn WorkspaceCache>) -> Self {
189        Self::with_deps(
190            db,
191            cache,
192            Arc::new(crate::conflict::LocalClaimTracker::new()),
193            Arc::new(NoOpEventPublisher),
194        )
195    }
196
197    /// Create a workspace manager with full dependency injection.
198    ///
199    /// Use this constructor when wiring a real `ClaimTracker` (e.g. Valkey-backed)
200    /// and/or a real `EventPublisher` (e.g. the protocol event bus).
201    pub fn with_deps(
202        db: PgPool,
203        cache: Arc<dyn WorkspaceCache>,
204        claim_tracker: Arc<dyn crate::conflict::ClaimTracker>,
205        events: Arc<dyn EventPublisher>,
206    ) -> Self {
207        Self {
208            workspaces: DashMap::new(),
209            agent_counters: DashMap::new(),
210            db,
211            cache,
212            last_touched: DashMap::new(),
213            claim_tracker,
214            events,
215        }
216    }
217
218    /// Return a reference to the underlying cache implementation.
219    pub fn cache(&self) -> &dyn WorkspaceCache {
220        self.cache.as_ref()
221    }
222
223
224    /// Auto-assign the next agent name for a repository.
225    ///
226    /// Returns "agent-1", "agent-2", etc. incrementing per repo.
227    pub fn next_agent_name(&self, repo_id: &Uuid) -> String {
228        let counter = self
229            .agent_counters
230            .entry(*repo_id)
231            .or_insert_with(|| AtomicU32::new(0));
232        let n = counter.value().fetch_add(1, Ordering::Relaxed) + 1;
233        format!("agent-{n}")
234    }
235
236    /// Create a new workspace for a session and register it.
237    #[allow(clippy::too_many_arguments)]
238    pub async fn create_workspace(
239        &self,
240        session_id: SessionId,
241        repo_id: RepoId,
242        agent_id: AgentId,
243        changeset_id: uuid::Uuid,
244        intent: String,
245        base_commit: String,
246        mode: WorkspaceMode,
247        agent_name: String,
248    ) -> Result<SessionId> {
249        let ws = SessionWorkspace::new(
250            session_id,
251            repo_id,
252            agent_id,
253            changeset_id,
254            intent,
255            base_commit,
256            mode,
257            agent_name,
258            self.db.clone(),
259        )
260        .await?;
261
262        // Write-through to L2 cache (fire-and-forget — Valkey failure
263        // does not block workspace creation).
264        let snapshot = crate::workspace::cache::WorkspaceSnapshot {
265            session_id: ws.session_id,
266            repo_id: ws.repo_id,
267            agent_id: ws.agent_id.clone(),
268            agent_name: ws.agent_name.clone(),
269            changeset_id: ws.changeset_id,
270            intent: ws.intent.clone(),
271            base_commit: ws.base_commit.clone(),
272            state: ws.state.as_str().to_string(),
273            mode: ws.mode.as_str().to_string(),
274        };
275        let cache = self.cache.clone();
276        tokio::spawn(async move {
277            if let Err(e) = cache.cache_workspace(&session_id, &snapshot).await {
278                tracing::warn!("L2 cache write failed on create: {e}");
279            }
280        });
281
282        self.workspaces.insert(session_id, ws);
283        Ok(session_id)
284    }
285
286    /// Get an immutable reference to a workspace.
287    pub fn get_workspace(
288        &self,
289        session_id: &SessionId,
290    ) -> Option<dashmap::mapref::one::Ref<'_, SessionId, SessionWorkspace>> {
291        let result = self.workspaces.get(session_id);
292        if result.is_some() {
293            self.touch_in_cache(session_id);
294        }
295        result
296    }
297
298    /// Get a mutable reference to a workspace.
299    pub fn get_workspace_mut(
300        &self,
301        session_id: &SessionId,
302    ) -> Option<dashmap::mapref::one::RefMut<'_, SessionId, SessionWorkspace>> {
303        let result = self.workspaces.get_mut(session_id);
304        if result.is_some() {
305            self.touch_in_cache(session_id);
306        }
307        result
308    }
309
310    /// Fire-and-forget L2 cache eviction for one or more session IDs.
311    /// Safe to call from sync contexts — silently skips if no Tokio runtime.
312    fn evict_from_cache(&self, session_ids: &[SessionId]) {
313        if let Ok(handle) = tokio::runtime::Handle::try_current() {
314            for &sid in session_ids {
315                let cache = self.cache.clone();
316                handle.spawn(async move {
317                    if let Err(e) = cache.evict(&sid).await {
318                        tracing::warn!("L2 cache evict failed: {e}");
319                    }
320                });
321            }
322        }
323    }
324
325    /// Fire-and-forget L2 cache TTL refresh.
326    /// Prevents cache entries from expiring during long-lived sessions.
327    fn touch_in_cache(&self, session_id: &SessionId) {
328        let now = Instant::now();
329        let should_touch = self
330            .last_touched
331            .get(session_id)
332            .is_none_or(|t| now.duration_since(*t) > TOUCH_DEBOUNCE);
333        if !should_touch {
334            return;
335        }
336        self.last_touched.insert(*session_id, now);
337        if let Ok(handle) = tokio::runtime::Handle::try_current() {
338            let sid = *session_id;
339            let cache = self.cache.clone();
340            handle.spawn(async move {
341                if let Err(e) = cache.touch(&sid).await {
342                    tracing::warn!("L2 cache touch failed: {e}");
343                }
344            });
345        }
346    }
347
348    /// Remove and drop a workspace.
349    pub fn destroy_workspace(&self, session_id: &SessionId) -> Option<SessionWorkspace> {
350        self.last_touched.remove(session_id);
351        self.evict_from_cache(&[*session_id]);
352        self.workspaces.remove(session_id).map(|(_, ws)| ws)
353    }
354
355    /// Count active workspaces for a specific repository.
356    pub fn active_count(&self, repo_id: RepoId) -> usize {
357        self.workspaces
358            .iter()
359            .filter(|entry| entry.value().repo_id == repo_id)
360            .count()
361    }
362
363    /// Return session IDs of all active workspaces for a repo,
364    /// optionally excluding one session.
365    pub fn active_sessions_for_repo(
366        &self,
367        repo_id: RepoId,
368        exclude_session: Option<SessionId>,
369    ) -> Vec<SessionId> {
370        self.workspaces
371            .iter()
372            .filter(|entry| {
373                entry.value().repo_id == repo_id
374                    && exclude_session.is_none_or(|ex| *entry.key() != ex)
375            })
376            .map(|entry| *entry.key())
377            .collect()
378    }
379
380    /// Garbage-collect expired persistent workspaces.
381    ///
382    /// Ephemeral workspaces are not GC'd here — they are destroyed when
383    /// the session disconnects. This only handles persistent workspaces
384    /// whose `expires_at` deadline has passed.
385    pub fn gc_expired(&self) -> Vec<SessionId> {
386        let now = Instant::now();
387        let mut expired = Vec::new();
388
389        // Collect IDs first to avoid holding DashMap guards during removal.
390        self.workspaces.iter().for_each(|entry| {
391            if let WorkspaceMode::Persistent {
392                expires_at: Some(deadline),
393            } = &entry.value().mode
394            {
395                if now >= *deadline {
396                    expired.push(*entry.key());
397                }
398            }
399        });
400
401        for sid in &expired {
402            self.last_touched.remove(sid);
403            self.workspaces.remove(sid);
404        }
405        self.evict_from_cache(&expired);
406
407        expired
408    }
409
410    /// Destroy workspaces for sessions that no longer exist.
411    ///
412    /// Pin-aware: non-terminal workspaces are stranded instead of evicted
413    /// when `DKOD_PIN_NONTERMINAL` is enabled (default: on). Terminal
414    /// workspaces are evicted as before. Callers without a Tokio runtime
415    /// fall back to legacy (immediate eviction, no pin guard).
416    pub fn cleanup_disconnected(&self, active_session_ids: &[uuid::Uuid]) {
417        // `block_in_place` panics on a current-thread runtime; fall through to
418        // the legacy sync path in that case (and for callers with no runtime).
419        if let Ok(handle) = tokio::runtime::Handle::try_current() {
420            if matches!(handle.runtime_flavor(), tokio::runtime::RuntimeFlavor::MultiThread) {
421                tokio::task::block_in_place(|| {
422                    handle.block_on(self.cleanup_disconnected_async(active_session_ids))
423                });
424                return;
425            }
426        }
427        // Fallback: pre-Epic-B behavior — immediate eviction with no pin guard.
428        let to_remove: Vec<uuid::Uuid> = self
429            .workspaces
430            .iter()
431            .filter(|entry| !active_session_ids.contains(entry.key()))
432            .map(|entry| *entry.key())
433            .collect();
434        for sid in &to_remove {
435            self.last_touched.remove(sid);
436            self.workspaces.remove(sid);
437        }
438        self.evict_from_cache(&to_remove);
439    }
440
441    /// Async pin-aware implementation of `cleanup_disconnected`.
442    ///
443    /// Candidates = sessions in-memory but NOT in the `active_session_ids` list.
444    /// If pinned (flag-on + non-terminal): skip.
445    /// Else if non-terminal (flag-off): strand with `CleanupDisconnected`.
446    /// Else (terminal): evict.
447    pub async fn cleanup_disconnected_async(&self, active_session_ids: &[uuid::Uuid]) {
448        let candidates: Vec<SessionId> = self
449            .workspaces
450            .iter()
451            .filter(|entry| !active_session_ids.contains(entry.key()))
452            .map(|entry| *entry.key())
453            .collect();
454
455        let flag_on = pin_flag_enabled();
456        let mut evicted = Vec::new();
457        for sid in candidates {
458            let non_terminal = self.should_pin(&sid).await;
459            if flag_on && non_terminal {
460                // Pinned: skip entirely.
461                continue;
462            }
463            if non_terminal {
464                // Flag is off but the workspace would have been pinned: strand
465                // instead of hard-deleting so the changeset can be recovered.
466                // strand() already calls evict_from_cache internally — do NOT
467                // push sid into evicted to avoid double-eviction.
468                if let Err(e) = self.strand(&sid, StrandReason::CleanupDisconnected).await {
469                    tracing::warn!("strand during cleanup_disconnected failed: {e}");
470                    // strand failed: ensure manual eviction so the entry is cleaned up.
471                    evicted.push(sid);
472                }
473            } else {
474                // Terminal: evict as today.
475                self.last_touched.remove(&sid);
476                self.workspaces.remove(&sid);
477                evicted.push(sid);
478            }
479        }
480        if !evicted.is_empty() {
481            self.evict_from_cache(&evicted);
482        }
483    }
484
485    /// Remove workspaces that are idle beyond `idle_ttl` or alive beyond `max_ttl`.
486    ///
487    /// Returns the list of expired session IDs. This complements [`gc_expired`]
488    /// (which handles persistent workspace deadlines) by enforcing activity-based
489    /// and hard-maximum lifetime limits on **all** workspaces.
490    ///
491    /// Pin-aware: non-terminal workspaces survive (they remain in memory) when
492    /// `DKOD_PIN_NONTERMINAL` is enabled (default: on). Terminal workspaces are
493    /// evicted as before. With `DKOD_PIN_NONTERMINAL=0`, legacy behavior: no pinning.
494    pub fn gc_expired_sessions(
495        &self,
496        idle_ttl: std::time::Duration,
497        max_ttl: std::time::Duration,
498    ) -> Vec<SessionId> {
499        // `block_in_place` only works on the multi-threaded runtime. On a
500        // current-thread runtime (e.g. `#[sqlx::test]`) it panics, so we fall
501        // through to the legacy sync path. Callers on a multi-threaded runtime
502        // get the pin-aware async path via block_in_place + block_on.
503        if let Ok(handle) = tokio::runtime::Handle::try_current() {
504            if matches!(handle.runtime_flavor(), tokio::runtime::RuntimeFlavor::MultiThread) {
505                return tokio::task::block_in_place(|| {
506                    handle.block_on(self.gc_expired_sessions_async(idle_ttl, max_ttl))
507                });
508            }
509        }
510        self.gc_expired_sessions_legacy(idle_ttl, max_ttl)
511    }
512
513    /// Activity-based GC with Epic B pin guard. Non-terminal workspaces survive
514    /// (they remain in memory). Terminal workspaces are evicted as before.
515    /// With `DKOD_PIN_NONTERMINAL=0`, legacy behavior: no pinning.
516    pub async fn gc_expired_sessions_async(
517        &self,
518        idle_ttl: std::time::Duration,
519        max_ttl: std::time::Duration,
520    ) -> Vec<SessionId> {
521        let now = tokio::time::Instant::now();
522        // Collect candidate session IDs without holding DashMap guards across awaits.
523        let candidates: Vec<SessionId> = self
524            .workspaces
525            .iter()
526            .filter(|entry| {
527                let ws = entry.value();
528                let idle = now.duration_since(ws.last_active);
529                let total = now.duration_since(ws.created_at);
530                idle > idle_ttl || total > max_ttl
531            })
532            .map(|entry| *entry.key())
533            .collect();
534
535        let flag_on = pin_flag_enabled();
536        let mut evicted = Vec::new();
537        for sid in candidates {
538            let non_terminal = self.should_pin(&sid).await;
539            if flag_on && non_terminal {
540                // Pinned: skip entirely.
541                continue;
542            }
543            if non_terminal {
544                // Flag is off but the workspace would have been pinned: strand
545                // instead of hard-deleting so the changeset can be recovered.
546                // strand() removes the in-memory entry itself; evict_from_cache
547                // is idempotent so the later sweep is harmless.
548                if let Err(e) = self.strand(&sid, StrandReason::IdleTtl).await {
549                    tracing::warn!("strand during gc failed: {e}");
550                    // strand failed: ensure manual eviction so the entry is cleaned up.
551                    self.last_touched.remove(&sid);
552                    self.workspaces.remove(&sid);
553                }
554            } else {
555                // Terminal: evict as today.
556                self.last_touched.remove(&sid);
557                self.workspaces.remove(&sid);
558            }
559            // Whether stranded or terminal-evicted, the session left the
560            // in-memory map — report it in `evicted` so callers see it.
561            evicted.push(sid);
562        }
563        if !evicted.is_empty() {
564            self.evict_from_cache(&evicted);
565        }
566        evicted
567    }
568
569    /// Legacy (pre-Epic-B) GC — no pin guard, no async.
570    ///
571    /// Used as fallback when there is no Tokio runtime available.
572    fn gc_expired_sessions_legacy(
573        &self,
574        idle_ttl: std::time::Duration,
575        max_ttl: std::time::Duration,
576    ) -> Vec<SessionId> {
577        let now = tokio::time::Instant::now();
578        let mut expired = Vec::new();
579        self.workspaces.retain(|_session_id, ws| {
580            let idle = now.duration_since(ws.last_active);
581            let total = now.duration_since(ws.created_at);
582            if idle > idle_ttl || total > max_ttl {
583                expired.push(ws.session_id);
584                false
585            } else {
586                true
587            }
588        });
589        for sid in &expired {
590            self.last_touched.remove(sid);
591        }
592        self.evict_from_cache(&expired);
593        expired
594    }
595
596    /// One-shot sweep at server boot: find orphaned non-terminal workspaces
597    /// (rows with no live in-memory workspace, changeset non-terminal,
598    /// stranded_at IS NULL, abandoned_at IS NULL) and mark them stranded so
599    /// callers surface SESSION_STRANDED and can resume. Returns count stranded.
600    pub async fn startup_reconcile(&self) -> Result<usize> {
601        let rows: Vec<(uuid::Uuid,)> = sqlx::query_as(
602            r#"
603            SELECT w.session_id
604              FROM session_workspaces w
605              JOIN changesets c ON c.id = w.changeset_id
606             WHERE w.stranded_at IS NULL
607               AND w.abandoned_at IS NULL
608               AND c.state NOT IN ('merged', 'rejected', 'closed', 'draft')
609            "#,
610        )
611        .fetch_all(&self.db)
612        .await
613        .map_err(|e| dk_core::Error::Internal(e.to_string()))?;
614
615        let mut count = 0;
616        for (sid,) in rows {
617            if self.workspaces.contains_key(&sid) {
618                continue; // safety belt if called while live (should be empty at boot)
619            }
620            self.strand(&sid, StrandReason::StartupReconcile).await?;
621            count += 1;
622        }
623        Ok(count)
624    }
625
626    /// Insert a pre-built workspace (test-only).
627    ///
628    /// Allows unit tests to insert workspaces with manipulated timestamps
629    /// without requiring a live database connection.
630    #[doc(hidden)]
631    pub fn insert_test_workspace(&self, ws: SessionWorkspace) {
632        let sid = ws.session_id;
633        self.workspaces.insert(sid, ws);
634    }
635
636    /// Total number of active workspaces across all repos.
637    pub fn total_active(&self) -> usize {
638        self.workspaces.len()
639    }
640
641    /// Mark a workspace as stranded. Idempotent: a second call on an already-
642    /// stranded row does not change stranded_at. Drops the in-memory entry,
643    /// releases all symbol locks held by the session, and emits a
644    /// `session.stranded` lifecycle event.
645    pub async fn strand(
646        &self,
647        session_id: &SessionId,
648        reason: StrandReason,
649    ) -> Result<()> {
650        // Fetch (repo_id, changeset_id) before mutating — idempotent even if
651        // the row is already stranded because COALESCE guards the update below.
652        let ids: Option<(Uuid, Uuid)> = sqlx::query_as(
653            r#"
654            SELECT repo_id, changeset_id
655            FROM session_workspaces
656            WHERE session_id = $1
657            LIMIT 1
658            "#,
659        )
660        .bind(session_id)
661        .fetch_optional(&self.db)
662        .await
663        .map_err(|e| dk_core::Error::Internal(e.to_string()))?;
664
665        sqlx::query(
666            r#"
667            UPDATE session_workspaces
668               SET stranded_at     = COALESCE(stranded_at, now()),
669                   stranded_reason = COALESCE(stranded_reason, $2)
670             WHERE session_id = $1
671            "#,
672        )
673        .bind(session_id)
674        .bind(reason.as_str())
675        .execute(&self.db)
676        .await
677        .map_err(|e| dk_core::Error::Internal(e.to_string()))?;
678
679        // Release all symbol locks held by this session (idempotent — returns
680        // empty vec if none are held).
681        if let Some((repo_id, changeset_id)) = ids {
682            self.claim_tracker
683                .release_locks(repo_id, *session_id)
684                .await;
685
686            self.events.publish_session_event(
687                "session.stranded",
688                *session_id,
689                changeset_id,
690                reason.as_str(),
691            );
692        }
693
694        crate::metrics::incr_workspace_stranded(reason.as_str());
695
696        self.last_touched.remove(session_id);
697        self.workspaces.remove(session_id);
698        self.evict_from_cache(&[*session_id]);
699        Ok(())
700    }
701
702    /// Return true when this workspace's changeset is in a non-terminal state
703    /// and the workspace should NOT be evicted by GC. See Epic B spec §Pin rule.
704    ///
705    /// Uses a single indexed query on (session_id) → (changeset_id); returns
706    /// false on missing workspace/changeset so the caller falls through to
707    /// the existing eviction path.
708    pub async fn should_pin(&self, session_id: &SessionId) -> bool {
709        let row: Option<(String,)> = match sqlx::query_as(
710            r#"
711            SELECT c.state
712            FROM session_workspaces w
713            JOIN changesets c ON c.id = w.changeset_id
714            WHERE w.session_id = $1
715            LIMIT 1
716            "#,
717        )
718        .bind(session_id)
719        .fetch_optional(&self.db)
720        .await
721        {
722            Ok(row) => row,
723            Err(e) => {
724                tracing::error!(
725                    session_id = %session_id,
726                    error = %e,
727                    "should_pin lookup failed; failing closed (treating as pinned)"
728                );
729                crate::metrics::incr_workspace_pinned("lookup_error");
730                return true;
731            }
732        };
733
734        let pinned = match row {
735            Some((state,)) => crate::changeset::ChangesetState::parse(&state)
736                .is_some_and(|s| !s.is_terminal()),
737            None => false,
738        };
739        if pinned {
740            crate::metrics::incr_workspace_pinned("non_terminal");
741        }
742        pinned
743    }
744
745    /// Describe which other sessions have modified a given file.
746    ///
747    /// Returns a formatted string like `"fn create_task modified by agent-2"`
748    /// or `"modified by agent-2, agent-3"`. Returns an empty string if no
749    /// other session has touched the file.
750    pub fn describe_other_modifiers(
751        &self,
752        file_path: &str,
753        repo_id: RepoId,
754        exclude_session: SessionId,
755    ) -> String {
756        let mut parts: Vec<String> = Vec::new();
757
758        for entry in self.workspaces.iter() {
759            let ws = entry.value();
760            if ws.repo_id != repo_id || ws.session_id == exclude_session {
761                continue;
762            }
763
764            // Check if this other session has the file in its overlay
765            if !ws.overlay.list_paths().contains(&file_path.to_string()) {
766                continue;
767            }
768
769            // Get changed symbols for this file from the session graph
770            let symbols = ws.graph.changed_symbols_for_file(file_path);
771            let agent = &ws.agent_name;
772
773            if symbols.is_empty() {
774                parts.push(format!("modified by {agent}"));
775            } else {
776                // Take up to 3 symbol names to keep it concise
777                let sym_list: Vec<&str> = symbols.iter().take(3).map(|s| s.as_str()).collect();
778                let sym_str = sym_list.join(", ");
779                if symbols.len() > 3 {
780                    parts.push(format!("{sym_str},... modified by {agent}"));
781                } else {
782                    parts.push(format!("{sym_str} modified by {agent}"));
783                }
784            }
785        }
786
787        parts.join("; ")
788    }
789
790    /// Terminal cleanup for a stranded workspace: mark the changeset rejected,
791    /// delete overlay rows, tombstone the workspace row, emit session.abandoned,
792    /// release any residual locks. Idempotent.
793    pub async fn abandon_stranded(
794        &self,
795        session_id: &SessionId,
796        reason: AbandonReason,
797    ) -> Result<()> {
798        // Fetch workspace row PK (id), changeset_id, repo_id, stranded_at, and prior abandoned_at.
799        type AbandonRow = (
800            uuid::Uuid,                              // id
801            Option<uuid::Uuid>,                      // changeset_id
802            uuid::Uuid,                              // repo_id
803            Option<chrono::DateTime<chrono::Utc>>,   // stranded_at
804            Option<chrono::DateTime<chrono::Utc>>,   // abandoned_at
805        );
806        let row: Option<AbandonRow> = sqlx::query_as(
807            "SELECT id, changeset_id, repo_id, stranded_at, abandoned_at
808               FROM session_workspaces WHERE session_id = $1",
809        )
810        .bind(session_id)
811        .fetch_optional(&self.db)
812        .await
813        .map_err(|e| dk_core::Error::Internal(e.to_string()))?;
814
815        let Some((workspace_id, changeset_id_opt, repo_id, stranded_at, already_abandoned)) = row else {
816            return Ok(()); // no row — idempotent no-op
817        };
818        if already_abandoned.is_some() {
819            return Ok(()); // already abandoned — idempotent no-op
820        }
821        if stranded_at.is_none() {
822            return Err(dk_core::Error::Internal(
823                "abandon precondition failed: workspace is not stranded".into(),
824            ));
825        }
826
827        // Wrap the three DB mutations in a single transaction so a mid-sequence
828        // crash does not leave the row in an inconsistent state.
829        let mut tx = self
830            .db
831            .begin()
832            .await
833            .map_err(|e| dk_core::Error::Internal(e.to_string()))?;
834
835        // 1. Drop overlay rows (inlined so we can use the transaction connection).
836        sqlx::query("DELETE FROM session_overlay_files WHERE workspace_id = $1")
837            .bind(workspace_id)
838            .execute(&mut *tx)
839            .await
840            .map_err(|e| dk_core::Error::Internal(e.to_string()))?;
841
842        // 2. Mark changeset rejected (skip if already in a terminal state).
843        if let Some(changeset_id) = changeset_id_opt {
844            sqlx::query(
845                "UPDATE changesets
846                    SET state = 'rejected',
847                        reason = $2,
848                        updated_at = now()
849                  WHERE id = $1
850                    AND state NOT IN ('merged', 'rejected', 'closed')",
851            )
852            .bind(changeset_id)
853            .bind(format!("session_abandoned:{}", reason.as_str()))
854            .execute(&mut *tx)
855            .await
856            .map_err(|e| dk_core::Error::Internal(e.to_string()))?;
857        }
858
859        // 3. Tombstone workspace row.
860        sqlx::query(
861            "UPDATE session_workspaces
862                SET abandoned_at     = now(),
863                    abandoned_reason = $2
864              WHERE session_id = $1",
865        )
866        .bind(session_id)
867        .bind(reason.as_str())
868        .execute(&mut *tx)
869        .await
870        .map_err(|e| dk_core::Error::Internal(e.to_string()))?;
871
872        tx.commit()
873            .await
874            .map_err(|e| dk_core::Error::Internal(e.to_string()))?;
875
876        // 4. Release any residual locks (safe if strand already released them).
877        let _ = self.claim_tracker.release_locks(repo_id, *session_id).await;
878
879        // 5. Emit event.
880        let cs_for_event = changeset_id_opt.unwrap_or_else(uuid::Uuid::nil);
881        self.events
882            .publish_session_event("session.abandoned", *session_id, cs_for_event, reason.as_str());
883
884        // 6. Ensure in-memory state is gone.
885        self.workspaces.remove(session_id);
886        self.last_touched.remove(session_id);
887
888        crate::metrics::incr_workspace_abandoned(reason.as_str());
889        Ok(())
890    }
891
892    /// Auto-abandon stranded workspaces past their TTL. Returns the number
893    /// of rows abandoned. Called from the engine's periodic GC loop.
894    pub async fn sweep_stranded(&self, ttl: std::time::Duration) -> Result<usize> {
895        let ttl_secs = ttl.as_secs() as f64;
896        let rows: Vec<(uuid::Uuid,)> = sqlx::query_as(
897            "SELECT session_id FROM session_workspaces
898              WHERE stranded_at IS NOT NULL
899                AND abandoned_at IS NULL
900                AND stranded_at + make_interval(secs => $1) < now()",
901        )
902        .bind(ttl_secs)
903        .fetch_all(&self.db)
904        .await
905        .map_err(|e| dk_core::Error::Internal(e.to_string()))?;
906
907        let mut count = 0;
908        for (sid,) in rows {
909            self.abandon_stranded(&sid, AbandonReason::AutoTtl).await?;
910            count += 1;
911        }
912        Ok(count)
913    }
914
915    /// Transactionally rehydrate a stranded workspace under a new session id.
916    ///
917    /// Preconditions (checked inside a `SELECT FOR UPDATE` transaction):
918    /// - Row with `session_id = dead_session` must exist.
919    /// - `abandoned_at` must be NULL (not already abandoned).
920    /// - Changeset state must not be terminal (merged/rejected/closed).
921    /// - `stranded_at` must be non-NULL (workspace is actually stranded).
922    /// - `agent_id` on the row must match the caller's `agent_id`.
923    ///
924    /// On success: rotates `session_id` to `new_session`, clears `stranded_at`,
925    /// sets `superseded_by = new_session` (stores the new session UUID), rehydrates
926    /// the in-memory overlay from DB, and inserts the workspace into the active map.
927    ///
928    /// Returns [`ResumeResult::Ok(new_session_id)`]. Use
929    /// [`WorkspaceManager::get_workspace`] to borrow the resumed workspace.
930    ///
931    /// # Note on `superseded_by`
932    /// The migration 016 declares `superseded_by UUID REFERENCES session_workspaces(id)`,
933    /// but this method stores the new `session_id` UUID (not the workspace `id` PK) in
934    /// that column. The FK semantics are intentionally relaxed here; a future migration
935    /// will correct the reference target or change the column's purpose.
936    pub async fn resume(
937        &self,
938        dead_session: &SessionId,
939        new_session: SessionId,
940        agent_id: &str,
941    ) -> Result<ResumeResult> {
942        // Early redirect check: has this dead_session already been resumed?
943        // If a redirect row exists, the rotation already committed — return the
944        // stored successor without touching the DB further.
945        let redirect: Option<(uuid::Uuid,)> = sqlx::query_as(
946            "SELECT successor_session_id FROM session_resume_redirects WHERE dead_session_id = $1",
947        )
948        .bind(dead_session)
949        .fetch_optional(&self.db)
950        .await
951        .map_err(|e| dk_core::Error::Internal(e.to_string()))?;
952        if let Some((successor,)) = redirect {
953            crate::metrics::incr_workspace_resumed("already_resumed");
954            return Ok(ResumeResult::AlreadyResumed(successor));
955        }
956
957        let mut tx = self
958            .db
959            .begin()
960            .await
961            .map_err(|e| dk_core::Error::Internal(e.to_string()))?;
962
963        // SELECT FOR UPDATE — lock the workspace row for the duration of this tx.
964        // The tuple has 12 fields; allow the complexity lint for this one query.
965        #[allow(clippy::type_complexity)]
966        let row: Option<(
967            uuid::Uuid,              // workspace id (PK)
968            uuid::Uuid,              // repo_id
969            Option<uuid::Uuid>,      // changeset_id
970            String,                  // agent_id
971            String,                  // intent
972            String,                  // base_commit_hash
973            String,                  // mode
974            String,                  // agent_name
975            Option<chrono::DateTime<chrono::Utc>>, // stranded_at
976            Option<chrono::DateTime<chrono::Utc>>, // abandoned_at
977            Option<uuid::Uuid>,      // superseded_by
978            Option<String>,          // changeset state (from JOIN)
979        )> = sqlx::query_as(
980            r#"
981            SELECT w.id, w.repo_id, w.changeset_id, w.agent_id,
982                   w.intent, w.base_commit_hash, w.mode, w.agent_name,
983                   w.stranded_at, w.abandoned_at,
984                   w.superseded_by, c.state
985              FROM session_workspaces w
986              LEFT JOIN changesets c ON c.id = w.changeset_id
987             WHERE w.session_id = $1
988             FOR UPDATE OF w
989            "#,
990        )
991        .bind(dead_session)
992        .fetch_optional(&mut *tx)
993        .await
994        .map_err(|e| dk_core::Error::Internal(e.to_string()))?;
995
996        let Some((
997            workspace_id, repo_id, changeset_id_opt, orig_agent, intent,
998            base_commit, mode_str, agent_name,
999            stranded_at, abandoned_at, superseded_by, changeset_state,
1000        )) = row else {
1001            tx.rollback().await.ok();
1002            return Ok(ResumeResult::NotStranded);
1003        };
1004
1005        if abandoned_at.is_some() {
1006            tx.rollback().await.ok();
1007            crate::metrics::incr_workspace_resumed("abandoned");
1008            return Ok(ResumeResult::Abandoned);
1009        }
1010        if let Some(state) = changeset_state.as_deref() {
1011            if crate::changeset::ChangesetState::parse(state)
1012                .is_some_and(|s| s.is_terminal())
1013            {
1014                tx.rollback().await.ok();
1015                crate::metrics::incr_workspace_resumed("abandoned");
1016                return Ok(ResumeResult::Abandoned);
1017            }
1018        }
1019        if stranded_at.is_none() {
1020            tx.rollback().await.ok();
1021            let result = match superseded_by {
1022                Some(stored_successor) => {
1023                    // Return the *stored* successor, not the caller-supplied new_session,
1024                    // so the client receives the actual session that won the resume race.
1025                    crate::metrics::incr_workspace_resumed("already_resumed");
1026                    ResumeResult::AlreadyResumed(stored_successor)
1027                }
1028                None => ResumeResult::NotStranded,
1029            };
1030            return Ok(result);
1031        }
1032        if orig_agent != agent_id {
1033            tx.rollback().await.ok();
1034            return Err(dk_core::Error::Internal(format!(
1035                "resume unauthorized: requires original agent_id '{orig_agent}'"
1036            )));
1037        }
1038
1039        // Persist the redirect BEFORE rotating session_id so a concurrent resume
1040        // sees a deterministic lookup path even if it races through the FOR UPDATE.
1041        sqlx::query(
1042            "INSERT INTO session_resume_redirects (dead_session_id, successor_session_id)
1043             VALUES ($1, $2)
1044             ON CONFLICT (dead_session_id) DO UPDATE
1045               SET successor_session_id = EXCLUDED.successor_session_id",
1046        )
1047        .bind(dead_session)
1048        .bind(new_session)
1049        .execute(&mut *tx)
1050        .await
1051        .map_err(|e| dk_core::Error::Internal(e.to_string()))?;
1052
1053        // Rotate session_id + clear stranded_at + record superseded_by.
1054        // Note: superseded_by stores new_session (a session_id UUID) even though
1055        // the FK references session_workspaces(id). See doc comment above.
1056        sqlx::query(
1057            r#"
1058            UPDATE session_workspaces
1059               SET session_id       = $2,
1060                   stranded_at      = NULL,
1061                   stranded_reason  = NULL,
1062                   superseded_by    = $2,
1063                   updated_at       = now()
1064             WHERE session_id = $1
1065            "#,
1066        )
1067        .bind(dead_session)
1068        .bind(new_session)
1069        .execute(&mut *tx)
1070        .await
1071        .map_err(|e| dk_core::Error::Internal(e.to_string()))?;
1072
1073        tx.commit()
1074            .await
1075            .map_err(|e| dk_core::Error::Internal(e.to_string()))?;
1076
1077        // OUT OF TRANSACTION: validate changeset_id, then rehydrate overlay + graph.
1078        let Some(changeset_id) = changeset_id_opt else {
1079            // The DB row has already been committed (session_id rotated, stranded_at cleared).
1080            // Re-strand so it remains recoverable rather than stuck in a non-live, non-stranded limbo.
1081            let _ = sqlx::query(
1082                "UPDATE session_workspaces
1083                    SET stranded_at     = now(),
1084                        stranded_reason = 'resume_failed'
1085                  WHERE session_id = $1",
1086            )
1087            .bind(new_session)
1088            .execute(&self.db)
1089            .await;
1090            return Err(dk_core::Error::Internal(
1091                "resume: workspace has no changeset_id — invalid state for resume".into(),
1092            ));
1093        };
1094
1095        let mode = if mode_str == "persistent" {
1096            crate::workspace::session_workspace::WorkspaceMode::Persistent { expires_at: None }
1097        } else {
1098            crate::workspace::session_workspace::WorkspaceMode::Ephemeral
1099        };
1100
1101        // Helper to re-strand the row if rehydration fails after the commit.
1102        // This compensates for the committed session_id rotation, leaving the row
1103        // recoverable (stranded) rather than stuck in a neither-live-nor-stranded state.
1104        let re_strand_on_failure = |db: PgPool, sid: uuid::Uuid, e: dk_core::Error| async move {
1105            let _ = sqlx::query(
1106                "UPDATE session_workspaces
1107                    SET stranded_at     = now(),
1108                        stranded_reason = 'resume_failed'
1109                  WHERE session_id = $1",
1110            )
1111            .bind(sid)
1112            .execute(&db)
1113            .await;
1114            e
1115        };
1116
1117        // Build the rehydrated SessionWorkspace in-memory WITHOUT inserting a new
1118        // DB row. The existing `session_workspaces` row has already been updated
1119        // above (session_id rotated to new_session, stranded_at cleared). We use
1120        // the `rehydrate` constructor that wires up in-memory structures pointing
1121        // at the existing workspace_id PK — no second INSERT.
1122        let mut ws = crate::workspace::session_workspace::SessionWorkspace::rehydrate(
1123            workspace_id,
1124            new_session,
1125            repo_id,
1126            orig_agent.clone(),
1127            changeset_id,
1128            intent,
1129            base_commit,
1130            mode,
1131            agent_name.clone(),
1132            self.db.clone(),
1133        );
1134
1135        // Restore overlay entries from DB. The overlay rows in session_overlay_files
1136        // reference the OLD workspace_id (PK). The new SessionWorkspace has a
1137        // freshly generated ws.id, so we use restore_from_workspace_id to load
1138        // from the old workspace PK row instead of ws.overlay.restore_from_db().
1139        if let Err(e) = ws.overlay
1140            .restore_from_workspace_id(&self.db, workspace_id)
1141            .await
1142            .map_err(|e| dk_core::Error::Internal(e.to_string()))
1143        {
1144            return Err(re_strand_on_failure(self.db.clone(), new_session, e).await);
1145        }
1146
1147        // Rebuild the semantic graph from overlay content.
1148        if let Err(e) = ws.reindex_from_overlay()
1149            .await
1150            .map_err(|e| dk_core::Error::Internal(format!("resume: reindex_from_overlay: {e}")))
1151        {
1152            return Err(re_strand_on_failure(self.db.clone(), new_session, e).await);
1153        }
1154
1155        // Eagerly re-acquire symbol locks over every changed symbol.
1156        // Collect (file_path, qualified_name) pairs from the overlay + graph.
1157        let mut sym_file_pairs: Vec<(String, String)> = Vec::new();
1158        for path in ws.overlay.list_paths() {
1159            for qname in ws.graph.changed_symbols_for_file(&path) {
1160                sym_file_pairs.push((path.clone(), qname));
1161            }
1162        }
1163
1164        let mut conflicts: Vec<ConflictingSymbol> = Vec::new();
1165        // Track freshly-acquired locks so we can roll back on contention.
1166        let mut acquired: Vec<(String, String)> = Vec::new(); // (file_path, qualified_name)
1167
1168        for (file_path, qname) in &sym_file_pairs {
1169            let claim = crate::conflict::SymbolClaim {
1170                session_id: new_session,
1171                agent_name: agent_name.clone(),
1172                qualified_name: qname.clone(),
1173                kind: dk_core::SymbolKind::Function, // conservative default; kind is not persisted per-lock
1174                first_touched_at: chrono::Utc::now(),
1175            };
1176            match self
1177                .claim_tracker
1178                .acquire_lock(repo_id, file_path, claim)
1179                .await
1180            {
1181                Ok(crate::conflict::AcquireOutcome::Fresh) => {
1182                    acquired.push((file_path.clone(), qname.clone()));
1183                }
1184                Ok(crate::conflict::AcquireOutcome::ReAcquired) => {
1185                    // Already held by this session — nothing to roll back.
1186                }
1187                Err(locked) => {
1188                    conflicts.push(ConflictingSymbol {
1189                        qualified_name: qname.clone(),
1190                        file_path: file_path.clone(),
1191                        claimant_session: locked.locked_by_session,
1192                        claimant_agent: locked.locked_by_agent.clone(),
1193                    });
1194                }
1195            }
1196        }
1197
1198        if !conflicts.is_empty() {
1199            // Roll back: release every freshly-acquired lock from this attempt.
1200            for (fp, qname) in &acquired {
1201                self.claim_tracker
1202                    .release_lock(repo_id, fp, new_session, qname)
1203                    .await;
1204            }
1205            // Re-strand so the DB row isn't left half-transitioned.
1206            self.strand(&new_session, StrandReason::Explicit).await?;
1207            crate::metrics::incr_workspace_resumed("contended");
1208            return Ok(ResumeResult::Contended(conflicts));
1209        }
1210
1211        // Insert into the in-memory active-workspace map.
1212        self.workspaces.insert(new_session, ws);
1213
1214        self.events.publish_session_event(
1215            "session.resumed",
1216            new_session,
1217            changeset_id,
1218            "resumed",
1219        );
1220
1221        crate::metrics::incr_workspace_resumed("ok");
1222        Ok(ResumeResult::Ok(new_session))
1223    }
1224
1225    /// List all active sessions for a given repository.
1226    pub fn list_sessions(&self, repo_id: RepoId) -> Vec<SessionInfo> {
1227        let now = Instant::now();
1228        self.workspaces
1229            .iter()
1230            .filter(|entry| entry.value().repo_id == repo_id)
1231            .map(|entry| {
1232                let ws = entry.value();
1233                SessionInfo {
1234                    session_id: ws.session_id,
1235                    agent_id: ws.agent_id.clone(),
1236                    agent_name: ws.agent_name.clone(),
1237                    intent: ws.intent.clone(),
1238                    repo_id: ws.repo_id,
1239                    changeset_id: ws.changeset_id,
1240                    state: ws.state.as_str().to_string(),
1241                    elapsed_secs: now.duration_since(ws.created_at).as_secs(),
1242                }
1243            })
1244            .collect()
1245    }
1246}
1247
1248#[cfg(test)]
1249mod tests {
1250    use super::*;
1251
1252    #[test]
1253    fn session_info_serializes_to_json() {
1254        let info = SessionInfo {
1255            session_id: Uuid::nil(),
1256            agent_id: "test-agent".to_string(),
1257            agent_name: "agent-1".to_string(),
1258            intent: "fix bug".to_string(),
1259            repo_id: Uuid::nil(),
1260            changeset_id: Uuid::nil(),
1261            state: "active".to_string(),
1262            elapsed_secs: 42,
1263        };
1264
1265        let json = serde_json::to_value(&info).expect("SessionInfo should serialize to JSON");
1266
1267        assert_eq!(json["agent_id"], "test-agent");
1268        assert_eq!(json["agent_name"], "agent-1");
1269        assert_eq!(json["intent"], "fix bug");
1270        assert_eq!(json["state"], "active");
1271        assert_eq!(json["elapsed_secs"], 42);
1272        assert_eq!(
1273            json["session_id"],
1274            "00000000-0000-0000-0000-000000000000"
1275        );
1276    }
1277
1278    #[test]
1279    fn session_info_all_fields_present_in_json() {
1280        let info = SessionInfo {
1281            session_id: Uuid::new_v4(),
1282            agent_id: "claude".to_string(),
1283            agent_name: "agent-1".to_string(),
1284            intent: "refactor".to_string(),
1285            repo_id: Uuid::new_v4(),
1286            changeset_id: Uuid::new_v4(),
1287            state: "submitted".to_string(),
1288            elapsed_secs: 100,
1289        };
1290
1291        let json = serde_json::to_value(&info).expect("serialize");
1292        let obj = json.as_object().expect("should be an object");
1293
1294        let expected_keys = [
1295            "session_id",
1296            "agent_id",
1297            "agent_name",
1298            "intent",
1299            "repo_id",
1300            "changeset_id",
1301            "state",
1302            "elapsed_secs",
1303        ];
1304        for key in &expected_keys {
1305            assert!(obj.contains_key(*key), "missing key: {}", key);
1306        }
1307        assert_eq!(obj.len(), expected_keys.len(), "unexpected extra keys in SessionInfo JSON");
1308    }
1309
1310    #[test]
1311    fn session_info_clone_preserves_values() {
1312        let info = SessionInfo {
1313            session_id: Uuid::new_v4(),
1314            agent_id: "agent-1".to_string(),
1315            agent_name: "feature-bot".to_string(),
1316            intent: "deploy".to_string(),
1317            repo_id: Uuid::new_v4(),
1318            changeset_id: Uuid::new_v4(),
1319            state: "active".to_string(),
1320            elapsed_secs: 5,
1321        };
1322
1323        let cloned = info.clone();
1324        assert_eq!(info.session_id, cloned.session_id);
1325        assert_eq!(info.agent_id, cloned.agent_id);
1326        assert_eq!(info.agent_name, cloned.agent_name);
1327        assert_eq!(info.intent, cloned.intent);
1328        assert_eq!(info.repo_id, cloned.repo_id);
1329        assert_eq!(info.changeset_id, cloned.changeset_id);
1330        assert_eq!(info.state, cloned.state);
1331        assert_eq!(info.elapsed_secs, cloned.elapsed_secs);
1332    }
1333
1334    #[tokio::test]
1335    async fn next_agent_name_increments_per_repo() {
1336        let db = PgPool::connect_lazy("postgres://localhost/nonexistent").unwrap();
1337        let mgr = WorkspaceManager::new(db);
1338        let repo1 = Uuid::new_v4();
1339        let repo2 = Uuid::new_v4();
1340
1341        assert_eq!(mgr.next_agent_name(&repo1), "agent-1");
1342        assert_eq!(mgr.next_agent_name(&repo1), "agent-2");
1343        assert_eq!(mgr.next_agent_name(&repo1), "agent-3");
1344
1345        // Different repo starts at 1
1346        assert_eq!(mgr.next_agent_name(&repo2), "agent-1");
1347        assert_eq!(mgr.next_agent_name(&repo2), "agent-2");
1348
1349        // Original repo continues
1350        assert_eq!(mgr.next_agent_name(&repo1), "agent-4");
1351    }
1352
1353    /// Integration-level test for list_sessions and WorkspaceManager.
1354    /// Requires PgPool which we cannot construct without a DB, so this
1355    /// is marked #[ignore]. Run with:
1356    ///   DATABASE_URL=postgres://localhost/dkod_test cargo test -p dk-engine -- --ignored
1357    #[test]
1358    #[ignore]
1359    fn list_sessions_returns_empty_for_unknown_repo() {
1360        // This test would require a PgPool. The structural tests above
1361        // validate SessionInfo independently.
1362    }
1363
1364    #[tokio::test]
1365    async fn describe_other_modifiers_empty_when_no_other_sessions() {
1366        let db = PgPool::connect_lazy("postgres://localhost/nonexistent").unwrap();
1367        let mgr = WorkspaceManager::new(db);
1368        let repo_id = Uuid::new_v4();
1369        let session_id = Uuid::new_v4();
1370
1371        let result = mgr.describe_other_modifiers("src/lib.rs", repo_id, session_id);
1372        assert!(result.is_empty());
1373    }
1374
1375    #[tokio::test]
1376    async fn describe_other_modifiers_shows_agent_name() {
1377        use crate::workspace::session_workspace::{SessionWorkspace, WorkspaceMode};
1378
1379        let db = PgPool::connect_lazy("postgres://localhost/nonexistent").unwrap();
1380        let mgr = WorkspaceManager::new(db);
1381        let repo_id = Uuid::new_v4();
1382
1383        let session1 = Uuid::new_v4();
1384        let session2 = Uuid::new_v4();
1385
1386        let mut ws2 = SessionWorkspace::new_test(
1387            session2,
1388            repo_id,
1389            "agent-2-id".to_string(),
1390            "fix bug".to_string(),
1391            "abc123".to_string(),
1392            WorkspaceMode::Ephemeral,
1393        );
1394        ws2.agent_name = "agent-2".to_string();
1395        ws2.overlay.write_local("src/lib.rs", b"content".to_vec(), false);
1396
1397        mgr.insert_test_workspace(ws2);
1398
1399        let result = mgr.describe_other_modifiers("src/lib.rs", repo_id, session1);
1400        assert_eq!(result, "modified by agent-2");
1401
1402        let result2 = mgr.describe_other_modifiers("src/other.rs", repo_id, session1);
1403        assert!(result2.is_empty());
1404
1405        let result3 = mgr.describe_other_modifiers("src/lib.rs", repo_id, session2);
1406        assert!(result3.is_empty());
1407    }
1408
1409    #[tokio::test]
1410    async fn describe_other_modifiers_includes_symbols() {
1411        use crate::workspace::session_workspace::{SessionWorkspace, WorkspaceMode};
1412        use dk_core::{Span, Symbol, SymbolKind, Visibility};
1413        use std::path::PathBuf;
1414
1415        let db = PgPool::connect_lazy("postgres://localhost/nonexistent").unwrap();
1416        let mgr = WorkspaceManager::new(db);
1417        let repo_id = Uuid::new_v4();
1418
1419        let session1 = Uuid::new_v4();
1420        let session2 = Uuid::new_v4();
1421
1422        let mut ws2 = SessionWorkspace::new_test(
1423            session2,
1424            repo_id,
1425            "agent-2-id".to_string(),
1426            "add feature".to_string(),
1427            "abc123".to_string(),
1428            WorkspaceMode::Ephemeral,
1429        );
1430        ws2.agent_name = "agent-2".to_string();
1431        ws2.overlay
1432            .write_local("src/tasks.rs", b"fn create_task() {}".to_vec(), true);
1433        ws2.graph.add_symbol(Symbol {
1434            id: Uuid::new_v4(),
1435            name: "create_task".to_string(),
1436            qualified_name: "create_task".to_string(),
1437            kind: SymbolKind::Function,
1438            visibility: Visibility::Public,
1439            file_path: PathBuf::from("src/tasks.rs"),
1440            span: Span {
1441                start_byte: 0,
1442                end_byte: 20,
1443            },
1444            signature: None,
1445            doc_comment: None,
1446            parent: None,
1447            last_modified_by: None,
1448            last_modified_intent: None,
1449        });
1450
1451        mgr.insert_test_workspace(ws2);
1452
1453        let result = mgr.describe_other_modifiers("src/tasks.rs", repo_id, session1);
1454        assert_eq!(result, "create_task modified by agent-2");
1455    }
1456}