@@ -1187,6 +1187,21 @@ def clean_job(self, job: MockJob, perform: bool = False) -> bool:
11871187
11881188 if job .path .exists ():
11891189 shutil .rmtree (job .path )
1190+
1191+ # Clear job from cache
1192+ with self ._job_cache_lock :
1193+ self ._job_cache .pop (job .identifier , None )
1194+
1195+ # Emit state change event (unscheduled = job removed)
1196+ from experimaestro .scheduler .state_status import JobStateChangedEvent
1197+
1198+ self ._notify_state_listeners (
1199+ JobStateChangedEvent (
1200+ job_id = job .identifier ,
1201+ state = "unscheduled" ,
1202+ )
1203+ )
1204+
11901205 return True
11911206 except Exception as e :
11921207 logger .warning ("Failed to clean job %s: %s" , job .identifier , e )
@@ -1547,6 +1562,88 @@ def get_process_info(self, job: MockJob) -> Optional[ProcessInfo]:
15471562 except (json .JSONDecodeError , OSError ):
15481563 return None
15491564
1565+ # =========================================================================
1566+ # Experiment deletion
1567+ # =========================================================================
1568+
1569+ def delete_experiment (
1570+ self , experiment_id : str , delete_jobs : bool = False , perform : bool = True
1571+ ) -> tuple [bool , str ]:
1572+ """Delete an experiment and optionally its job data
1573+
1574+ Args:
1575+ experiment_id: Experiment identifier to delete
1576+ delete_jobs: If True, also delete job directories (default: False)
1577+ perform: If True, actually perform deletion; if False, just check
1578+
1579+ Returns:
1580+ Tuple of (success, message)
1581+ """
1582+ import shutil
1583+
1584+ # Check for running jobs first
1585+ jobs = self .get_jobs (experiment_id = experiment_id )
1586+ running_jobs = [j for j in jobs if j .state and j .state .running ()]
1587+ if running_jobs :
1588+ return False , f"Cannot delete: { len (running_jobs )} jobs are still running"
1589+
1590+ # Find experiment directories (v2 layout)
1591+ exp_dir = self .workspace_path / "experiments" / experiment_id
1592+ events_dir = self ._experiments_dir / experiment_id
1593+
1594+ # Check for v1 layout
1595+ v1_exp_dir = self .workspace_path / "xp" / experiment_id
1596+
1597+ if not exp_dir .exists () and not v1_exp_dir .exists ():
1598+ return False , f"Experiment { experiment_id } not found"
1599+
1600+ if not perform :
1601+ return True , f"Experiment { experiment_id } can be deleted"
1602+
1603+ errors = []
1604+
1605+ # Delete job directories if requested
1606+ if delete_jobs :
1607+ for job in jobs :
1608+ if job .path and job .path .exists ():
1609+ try :
1610+ shutil .rmtree (job .path )
1611+ except OSError as e :
1612+ errors .append (f"Failed to delete job { job .identifier } : { e } " )
1613+
1614+ # Delete v2 experiment directory
1615+ if exp_dir .exists ():
1616+ try :
1617+ shutil .rmtree (exp_dir )
1618+ except OSError as e :
1619+ errors .append (f"Failed to delete experiment dir: { e } " )
1620+
1621+ # Delete events directory
1622+ if events_dir .exists ():
1623+ try :
1624+ shutil .rmtree (events_dir )
1625+ except OSError as e :
1626+ errors .append (f"Failed to delete events dir: { e } " )
1627+
1628+ # Delete v1 experiment directory
1629+ if v1_exp_dir .exists ():
1630+ try :
1631+ shutil .rmtree (v1_exp_dir )
1632+ except OSError as e :
1633+ errors .append (f"Failed to delete v1 experiment dir: { e } " )
1634+
1635+ # Clear caches
1636+ self ._clear_experiment_cache (experiment_id )
1637+ with self ._job_cache_lock :
1638+ job_ids_to_remove = [j .identifier for j in jobs ]
1639+ for job_id in job_ids_to_remove :
1640+ self ._job_cache .pop (job_id , None )
1641+
1642+ if errors :
1643+ return False , f"Partial deletion: { '; ' .join (errors )} "
1644+
1645+ return True , f"Deleted experiment { experiment_id } "
1646+
15501647 # =========================================================================
15511648 # Lifecycle
15521649 # =========================================================================
0 commit comments