1use 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
22fn 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
36pub 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
52pub struct NoOpEventPublisher;
54
55impl EventPublisher for NoOpEventPublisher {
56 fn publish_session_event(&self, _: &str, _: uuid::Uuid, _: uuid::Uuid, _: &str) {}
57}
58
59#[derive(Debug)]
63pub enum ResumeResult {
64 Ok(SessionId),
73 Contended(Vec<ConflictingSymbol>),
77 AlreadyResumed(SessionId),
80 Abandoned,
82 NotStranded,
85}
86
87#[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#[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#[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#[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
153const TOUCH_DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(30);
157
158pub struct WorkspaceManager {
168 workspaces: DashMap<SessionId, SessionWorkspace>,
169 agent_counters: DashMap<Uuid, AtomicU32>,
170 db: PgPool,
171 cache: Arc<dyn WorkspaceCache>,
172 last_touched: DashMap<SessionId, Instant>,
174 claim_tracker: Arc<dyn crate::conflict::ClaimTracker>,
175 events: Arc<dyn EventPublisher>,
176}
177
178impl WorkspaceManager {
179 pub fn new(db: PgPool) -> Self {
181 Self::with_cache(db, Arc::new(NoOpCache))
182 }
183
184 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 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 pub fn cache(&self) -> &dyn WorkspaceCache {
220 self.cache.as_ref()
221 }
222
223
224 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 #[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 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 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 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 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 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 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 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 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 pub fn gc_expired(&self) -> Vec<SessionId> {
386 let now = Instant::now();
387 let mut expired = Vec::new();
388
389 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 pub fn cleanup_disconnected(&self, active_session_ids: &[uuid::Uuid]) {
417 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 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 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 continue;
462 }
463 if non_terminal {
464 if let Err(e) = self.strand(&sid, StrandReason::CleanupDisconnected).await {
469 tracing::warn!("strand during cleanup_disconnected failed: {e}");
470 evicted.push(sid);
472 }
473 } else {
474 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 pub fn gc_expired_sessions(
495 &self,
496 idle_ttl: std::time::Duration,
497 max_ttl: std::time::Duration,
498 ) -> Vec<SessionId> {
499 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 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 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 continue;
542 }
543 if non_terminal {
544 if let Err(e) = self.strand(&sid, StrandReason::IdleTtl).await {
549 tracing::warn!("strand during gc failed: {e}");
550 self.last_touched.remove(&sid);
552 self.workspaces.remove(&sid);
553 }
554 } else {
555 self.last_touched.remove(&sid);
557 self.workspaces.remove(&sid);
558 }
559 evicted.push(sid);
562 }
563 if !evicted.is_empty() {
564 self.evict_from_cache(&evicted);
565 }
566 evicted
567 }
568
569 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 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; }
620 self.strand(&sid, StrandReason::StartupReconcile).await?;
621 count += 1;
622 }
623 Ok(count)
624 }
625
626 #[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 pub fn total_active(&self) -> usize {
638 self.workspaces.len()
639 }
640
641 pub async fn strand(
646 &self,
647 session_id: &SessionId,
648 reason: StrandReason,
649 ) -> Result<()> {
650 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 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 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 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 if !ws.overlay.list_paths().contains(&file_path.to_string()) {
766 continue;
767 }
768
769 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 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 pub async fn abandon_stranded(
794 &self,
795 session_id: &SessionId,
796 reason: AbandonReason,
797 ) -> Result<()> {
798 type AbandonRow = (
800 uuid::Uuid, Option<uuid::Uuid>, uuid::Uuid, Option<chrono::DateTime<chrono::Utc>>, Option<chrono::DateTime<chrono::Utc>>, );
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(()); };
818 if already_abandoned.is_some() {
819 return Ok(()); }
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 let mut tx = self
830 .db
831 .begin()
832 .await
833 .map_err(|e| dk_core::Error::Internal(e.to_string()))?;
834
835 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 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 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 let _ = self.claim_tracker.release_locks(repo_id, *session_id).await;
878
879 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 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 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 pub async fn resume(
937 &self,
938 dead_session: &SessionId,
939 new_session: SessionId,
940 agent_id: &str,
941 ) -> Result<ResumeResult> {
942 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 #[allow(clippy::type_complexity)]
966 let row: Option<(
967 uuid::Uuid, uuid::Uuid, Option<uuid::Uuid>, String, String, String, String, String, Option<chrono::DateTime<chrono::Utc>>, Option<chrono::DateTime<chrono::Utc>>, Option<uuid::Uuid>, Option<String>, )> = 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 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 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 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 let Some(changeset_id) = changeset_id_opt else {
1079 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 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 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 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 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 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 let mut acquired: Vec<(String, String)> = Vec::new(); 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, 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 }
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 for (fp, qname) in &acquired {
1201 self.claim_tracker
1202 .release_lock(repo_id, fp, new_session, qname)
1203 .await;
1204 }
1205 self.strand(&new_session, StrandReason::Explicit).await?;
1207 crate::metrics::incr_workspace_resumed("contended");
1208 return Ok(ResumeResult::Contended(conflicts));
1209 }
1210
1211 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 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 assert_eq!(mgr.next_agent_name(&repo2), "agent-1");
1347 assert_eq!(mgr.next_agent_name(&repo2), "agent-2");
1348
1349 assert_eq!(mgr.next_agent_name(&repo1), "agent-4");
1351 }
1352
1353 #[test]
1358 #[ignore]
1359 fn list_sessions_returns_empty_for_unknown_repo() {
1360 }
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}