experimaestro 2.0.0b8__py3-none-any.whl → 2.0.0b17__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of experimaestro might be problematic. Click here for more details.
- experimaestro/__init__.py +12 -5
- experimaestro/cli/__init__.py +239 -126
- experimaestro/cli/filter.py +48 -23
- experimaestro/cli/jobs.py +253 -71
- experimaestro/cli/refactor.py +1 -2
- experimaestro/commandline.py +7 -4
- experimaestro/connectors/__init__.py +9 -1
- experimaestro/connectors/local.py +43 -3
- experimaestro/core/arguments.py +18 -18
- experimaestro/core/identifier.py +11 -11
- experimaestro/core/objects/config.py +96 -39
- experimaestro/core/objects/config_walk.py +3 -3
- experimaestro/core/{subparameters.py → partial.py} +16 -16
- experimaestro/core/partial_lock.py +394 -0
- experimaestro/core/types.py +12 -15
- experimaestro/dynamic.py +290 -0
- experimaestro/experiments/__init__.py +6 -2
- experimaestro/experiments/cli.py +217 -50
- experimaestro/experiments/configuration.py +24 -0
- experimaestro/generators.py +5 -5
- experimaestro/ipc.py +118 -1
- experimaestro/launcherfinder/__init__.py +2 -2
- experimaestro/launcherfinder/registry.py +6 -7
- experimaestro/launcherfinder/specs.py +2 -9
- experimaestro/launchers/slurm/__init__.py +2 -2
- experimaestro/launchers/slurm/base.py +62 -0
- experimaestro/locking.py +957 -1
- experimaestro/notifications.py +89 -201
- experimaestro/progress.py +63 -366
- experimaestro/rpyc.py +0 -2
- experimaestro/run.py +29 -2
- experimaestro/scheduler/__init__.py +8 -1
- experimaestro/scheduler/base.py +629 -53
- experimaestro/scheduler/dependencies.py +20 -16
- experimaestro/scheduler/experiment.py +732 -167
- experimaestro/scheduler/interfaces.py +316 -101
- experimaestro/scheduler/jobs.py +58 -20
- experimaestro/scheduler/remote/adaptive_sync.py +265 -0
- experimaestro/scheduler/remote/client.py +171 -117
- experimaestro/scheduler/remote/protocol.py +8 -193
- experimaestro/scheduler/remote/server.py +95 -71
- experimaestro/scheduler/services.py +53 -28
- experimaestro/scheduler/state_provider.py +663 -2430
- experimaestro/scheduler/state_status.py +1247 -0
- experimaestro/scheduler/transient.py +31 -0
- experimaestro/scheduler/workspace.py +1 -1
- experimaestro/scheduler/workspace_state_provider.py +1273 -0
- experimaestro/scriptbuilder.py +4 -4
- experimaestro/settings.py +36 -0
- experimaestro/tests/conftest.py +33 -5
- experimaestro/tests/connectors/bin/executable.py +1 -1
- experimaestro/tests/fixtures/pre_experiment/experiment_check_env.py +16 -0
- experimaestro/tests/fixtures/pre_experiment/experiment_check_mock.py +14 -0
- experimaestro/tests/fixtures/pre_experiment/experiment_simple.py +12 -0
- experimaestro/tests/fixtures/pre_experiment/pre_setup_env.py +5 -0
- experimaestro/tests/fixtures/pre_experiment/pre_setup_error.py +3 -0
- experimaestro/tests/fixtures/pre_experiment/pre_setup_mock.py +8 -0
- experimaestro/tests/launchers/bin/test.py +1 -0
- experimaestro/tests/launchers/test_slurm.py +9 -9
- experimaestro/tests/partial_reschedule.py +46 -0
- experimaestro/tests/restart.py +3 -3
- experimaestro/tests/restart_main.py +1 -0
- experimaestro/tests/scripts/notifyandwait.py +1 -0
- experimaestro/tests/task_partial.py +38 -0
- experimaestro/tests/task_tokens.py +2 -2
- experimaestro/tests/tasks/test_dynamic.py +6 -6
- experimaestro/tests/test_dependencies.py +3 -3
- experimaestro/tests/test_deprecated.py +15 -15
- experimaestro/tests/test_dynamic_locking.py +317 -0
- experimaestro/tests/test_environment.py +24 -14
- experimaestro/tests/test_experiment.py +171 -36
- experimaestro/tests/test_identifier.py +25 -25
- experimaestro/tests/test_identifier_stability.py +3 -5
- experimaestro/tests/test_multitoken.py +2 -4
- experimaestro/tests/{test_subparameters.py → test_partial.py} +25 -25
- experimaestro/tests/test_partial_paths.py +81 -138
- experimaestro/tests/test_pre_experiment.py +219 -0
- experimaestro/tests/test_progress.py +2 -8
- experimaestro/tests/test_remote_state.py +560 -99
- experimaestro/tests/test_stray_jobs.py +261 -0
- experimaestro/tests/test_tasks.py +1 -2
- experimaestro/tests/test_token_locking.py +52 -67
- experimaestro/tests/test_tokens.py +5 -6
- experimaestro/tests/test_transient.py +225 -0
- experimaestro/tests/test_workspace_state_provider.py +768 -0
- experimaestro/tests/token_reschedule.py +1 -3
- experimaestro/tests/utils.py +2 -7
- experimaestro/tokens.py +227 -372
- experimaestro/tools/diff.py +1 -0
- experimaestro/tools/documentation.py +4 -5
- experimaestro/tools/jobs.py +1 -2
- experimaestro/tui/app.py +438 -1966
- experimaestro/tui/app.tcss +162 -0
- experimaestro/tui/dialogs.py +172 -0
- experimaestro/tui/log_viewer.py +253 -3
- experimaestro/tui/messages.py +137 -0
- experimaestro/tui/utils.py +54 -0
- experimaestro/tui/widgets/__init__.py +23 -0
- experimaestro/tui/widgets/experiments.py +468 -0
- experimaestro/tui/widgets/global_services.py +238 -0
- experimaestro/tui/widgets/jobs.py +972 -0
- experimaestro/tui/widgets/log.py +156 -0
- experimaestro/tui/widgets/orphans.py +363 -0
- experimaestro/tui/widgets/runs.py +185 -0
- experimaestro/tui/widgets/services.py +314 -0
- experimaestro/tui/widgets/stray_jobs.py +528 -0
- experimaestro/utils/__init__.py +1 -1
- experimaestro/utils/environment.py +105 -22
- experimaestro/utils/fswatcher.py +124 -0
- experimaestro/utils/jobs.py +1 -2
- experimaestro/utils/jupyter.py +1 -2
- experimaestro/utils/logging.py +72 -0
- experimaestro/version.py +2 -2
- experimaestro/webui/__init__.py +9 -0
- experimaestro/webui/app.py +117 -0
- experimaestro/{server → webui}/data/index.css +66 -11
- experimaestro/webui/data/index.css.map +1 -0
- experimaestro/{server → webui}/data/index.js +82763 -87217
- experimaestro/webui/data/index.js.map +1 -0
- experimaestro/webui/routes/__init__.py +5 -0
- experimaestro/webui/routes/auth.py +53 -0
- experimaestro/webui/routes/proxy.py +117 -0
- experimaestro/webui/server.py +200 -0
- experimaestro/webui/state_bridge.py +152 -0
- experimaestro/webui/websocket.py +413 -0
- {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/METADATA +5 -6
- experimaestro-2.0.0b17.dist-info/RECORD +219 -0
- experimaestro/cli/progress.py +0 -269
- experimaestro/scheduler/state.py +0 -75
- experimaestro/scheduler/state_db.py +0 -437
- experimaestro/scheduler/state_sync.py +0 -891
- experimaestro/server/__init__.py +0 -467
- experimaestro/server/data/index.css.map +0 -1
- experimaestro/server/data/index.js.map +0 -1
- experimaestro/tests/test_cli_jobs.py +0 -615
- experimaestro/tests/test_file_progress.py +0 -425
- experimaestro/tests/test_file_progress_integration.py +0 -477
- experimaestro/tests/test_state_db.py +0 -434
- experimaestro-2.0.0b8.dist-info/RECORD +0 -187
- /experimaestro/{server → webui}/data/1815e00441357e01619e.ttf +0 -0
- /experimaestro/{server → webui}/data/2463b90d9a316e4e5294.woff2 +0 -0
- /experimaestro/{server → webui}/data/2582b0e4bcf85eceead0.ttf +0 -0
- /experimaestro/{server → webui}/data/89999bdf5d835c012025.woff2 +0 -0
- /experimaestro/{server → webui}/data/914997e1bdfc990d0897.ttf +0 -0
- /experimaestro/{server → webui}/data/c210719e60948b211a12.woff2 +0 -0
- /experimaestro/{server → webui}/data/favicon.ico +0 -0
- /experimaestro/{server → webui}/data/index.html +0 -0
- /experimaestro/{server → webui}/data/login.html +0 -0
- /experimaestro/{server → webui}/data/manifest.json +0 -0
- {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/WHEEL +0 -0
- {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/entry_points.txt +0 -0
- {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
"""Tests for WorkspaceStateProvider
|
|
2
|
+
|
|
3
|
+
Tests cover:
|
|
4
|
+
1. Detection of v1 and v2 experiment layouts
|
|
5
|
+
2. Event detection when events-*.jsonl files are updated
|
|
6
|
+
3. Reading jobs, tags, dependencies from status.json
|
|
7
|
+
4. Getting experiment runs (multiple runs per experiment)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import pytest
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from experimaestro.scheduler.workspace_state_provider import WorkspaceStateProvider
|
|
17
|
+
from experimaestro.scheduler.state_status import (
|
|
18
|
+
JobStateChangedEvent,
|
|
19
|
+
JobProgressEvent,
|
|
20
|
+
ExperimentUpdatedEvent,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# =============================================================================
|
|
25
|
+
# Mock Workspace Helpers - Reusable for other tests
|
|
26
|
+
# =============================================================================
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def create_v1_experiment(
|
|
30
|
+
workspace: Path,
|
|
31
|
+
experiment_id: str,
|
|
32
|
+
jobs: list[
|
|
33
|
+
tuple[str, str, str]
|
|
34
|
+
], # (task_id, job_id, status: "done"|"error"|"running")
|
|
35
|
+
) -> Path:
|
|
36
|
+
"""Create a v1 layout experiment.
|
|
37
|
+
|
|
38
|
+
v1 layout: xp/{exp-id}/jobs/{task_id}/{job_id} -> symlink to jobs/{task_id}/{job_id}
|
|
39
|
+
"""
|
|
40
|
+
exp_dir = workspace / "xp" / experiment_id
|
|
41
|
+
exp_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
|
|
43
|
+
jobs_dir = exp_dir / "jobs"
|
|
44
|
+
jobs_dir.mkdir(exist_ok=True)
|
|
45
|
+
|
|
46
|
+
# Create actual job directories in workspace/jobs/
|
|
47
|
+
actual_jobs_dir = workspace / "jobs"
|
|
48
|
+
|
|
49
|
+
for task_id, job_id, status in jobs:
|
|
50
|
+
# Create actual job directory
|
|
51
|
+
job_path = actual_jobs_dir / task_id / job_id
|
|
52
|
+
job_path.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
|
|
54
|
+
# Create status file based on status
|
|
55
|
+
scriptname = task_id.rsplit(".", 1)[-1]
|
|
56
|
+
if status == "done":
|
|
57
|
+
(job_path / f"{scriptname}.done").touch()
|
|
58
|
+
elif status == "error":
|
|
59
|
+
(job_path / f"{scriptname}.failed").touch()
|
|
60
|
+
# "running" has no status file
|
|
61
|
+
|
|
62
|
+
# Create symlink in experiment jobs/
|
|
63
|
+
task_jobs_dir = jobs_dir / task_id
|
|
64
|
+
task_jobs_dir.mkdir(exist_ok=True)
|
|
65
|
+
link_path = task_jobs_dir / job_id
|
|
66
|
+
link_path.symlink_to(job_path)
|
|
67
|
+
|
|
68
|
+
return exp_dir
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def create_v2_experiment(
|
|
72
|
+
workspace: Path,
|
|
73
|
+
experiment_id: str,
|
|
74
|
+
runs: list[tuple[str, str, list[tuple[str, str, str]]]], # (run_id, status, jobs)
|
|
75
|
+
current_run: Optional[str] = None,
|
|
76
|
+
) -> Path:
|
|
77
|
+
"""Create a v2 layout experiment with multiple runs.
|
|
78
|
+
|
|
79
|
+
v2 layout:
|
|
80
|
+
experiments/{exp-id}/{run-id}/status.json
|
|
81
|
+
.events/experiments/{exp-id} -> ../../experiments/{exp-id}/{current_run}
|
|
82
|
+
"""
|
|
83
|
+
exp_dir = workspace / "experiments" / experiment_id
|
|
84
|
+
exp_dir.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
|
|
86
|
+
for run_id, run_status, jobs in runs:
|
|
87
|
+
run_dir = exp_dir / run_id
|
|
88
|
+
run_dir.mkdir(exist_ok=True)
|
|
89
|
+
|
|
90
|
+
# Create jobs.jsonl with job info and count finished/failed
|
|
91
|
+
jobs_jsonl_lines = []
|
|
92
|
+
finished_count = 0
|
|
93
|
+
failed_count = 0
|
|
94
|
+
for task_id, job_id, job_status in jobs:
|
|
95
|
+
job_info = {
|
|
96
|
+
"job_id": job_id,
|
|
97
|
+
"task_id": task_id,
|
|
98
|
+
"tags": {"task": task_id.split(".")[-1]},
|
|
99
|
+
"timestamp": 1704103200.0, # 2024-01-01T10:00:00
|
|
100
|
+
}
|
|
101
|
+
jobs_jsonl_lines.append(json.dumps(job_info))
|
|
102
|
+
|
|
103
|
+
# Count finished and failed jobs
|
|
104
|
+
if job_status == "done":
|
|
105
|
+
finished_count += 1
|
|
106
|
+
elif job_status == "error":
|
|
107
|
+
failed_count += 1
|
|
108
|
+
|
|
109
|
+
# Create job directory with status marker
|
|
110
|
+
job_dir = workspace / "jobs" / task_id / job_id
|
|
111
|
+
job_dir.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
scriptname = task_id.rsplit(".", 1)[-1]
|
|
113
|
+
if job_status == "done":
|
|
114
|
+
(job_dir / f"{scriptname}.done").touch()
|
|
115
|
+
elif job_status == "error":
|
|
116
|
+
(job_dir / f"{scriptname}.failed").touch()
|
|
117
|
+
# "running" and "waiting" have no marker files
|
|
118
|
+
|
|
119
|
+
(run_dir / "jobs.jsonl").write_text("\n".join(jobs_jsonl_lines))
|
|
120
|
+
|
|
121
|
+
status_data = {
|
|
122
|
+
"version": 1,
|
|
123
|
+
"experiment_id": experiment_id,
|
|
124
|
+
"run_id": run_id,
|
|
125
|
+
"events_count": 0,
|
|
126
|
+
"hostname": "test-host",
|
|
127
|
+
"started_at": "2026-01-01T10:00:00",
|
|
128
|
+
"ended_at": "2026-01-01T11:00:00" if run_status != "active" else None,
|
|
129
|
+
"status": run_status,
|
|
130
|
+
"finished_jobs": finished_count,
|
|
131
|
+
"failed_jobs": failed_count,
|
|
132
|
+
"dependencies": {},
|
|
133
|
+
"services": {},
|
|
134
|
+
}
|
|
135
|
+
(run_dir / "status.json").write_text(json.dumps(status_data))
|
|
136
|
+
|
|
137
|
+
# Also create environment.json for compatibility
|
|
138
|
+
env_data = {"run": {"status": run_status}}
|
|
139
|
+
(run_dir / "environment.json").write_text(json.dumps(env_data))
|
|
140
|
+
|
|
141
|
+
# Create symlink for current run
|
|
142
|
+
if current_run:
|
|
143
|
+
symlinks_dir = workspace / ".events" / "experiments"
|
|
144
|
+
symlinks_dir.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
symlink = symlinks_dir / experiment_id
|
|
146
|
+
if symlink.exists():
|
|
147
|
+
symlink.unlink()
|
|
148
|
+
target = Path("../..") / "experiments" / experiment_id / current_run
|
|
149
|
+
symlink.symlink_to(target)
|
|
150
|
+
|
|
151
|
+
return exp_dir
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@pytest.fixture
|
|
155
|
+
def mock_workspace(tmp_path):
|
|
156
|
+
"""Create a comprehensive mock workspace with v1 and v2 experiments.
|
|
157
|
+
|
|
158
|
+
Structure:
|
|
159
|
+
workspace/
|
|
160
|
+
.events/experiments/
|
|
161
|
+
v2-multi-run -> ../../experiments/v2-multi-run/20260101_120000
|
|
162
|
+
v2-failed -> ../../experiments/v2-failed/20260101_110000
|
|
163
|
+
xp/ # v1 layout
|
|
164
|
+
v1-mixed/
|
|
165
|
+
jobs/
|
|
166
|
+
pkg.TaskA/job-a1 -> ... # done
|
|
167
|
+
pkg.TaskB/job-b1 -> ... # error
|
|
168
|
+
experiments/ # v2 layout
|
|
169
|
+
v2-multi-run/
|
|
170
|
+
20260101_100000/ # older run, completed
|
|
171
|
+
20260101_120000/ # current run, active
|
|
172
|
+
v2-failed/
|
|
173
|
+
20260101_110000/ # failed run
|
|
174
|
+
"""
|
|
175
|
+
workspace = tmp_path / "workspace"
|
|
176
|
+
workspace.mkdir()
|
|
177
|
+
|
|
178
|
+
# v1 experiment with mixed job statuses
|
|
179
|
+
create_v1_experiment(
|
|
180
|
+
workspace,
|
|
181
|
+
"v1-mixed",
|
|
182
|
+
jobs=[
|
|
183
|
+
("pkg.TaskA", "job-a1", "done"),
|
|
184
|
+
("pkg.TaskA", "job-a2", "done"),
|
|
185
|
+
("pkg.TaskB", "job-b1", "error"),
|
|
186
|
+
],
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# v2 experiment with multiple runs
|
|
190
|
+
create_v2_experiment(
|
|
191
|
+
workspace,
|
|
192
|
+
"v2-multi-run",
|
|
193
|
+
runs=[
|
|
194
|
+
(
|
|
195
|
+
"20260101_100000",
|
|
196
|
+
"completed",
|
|
197
|
+
[
|
|
198
|
+
("pkg.OldTask", "old-job-1", "done"),
|
|
199
|
+
],
|
|
200
|
+
),
|
|
201
|
+
(
|
|
202
|
+
"20260101_120000",
|
|
203
|
+
"active",
|
|
204
|
+
[
|
|
205
|
+
("pkg.NewTask", "new-job-1", "running"),
|
|
206
|
+
("pkg.NewTask", "new-job-2", "waiting"),
|
|
207
|
+
("pkg.NewTask", "new-job-3", "done"),
|
|
208
|
+
],
|
|
209
|
+
),
|
|
210
|
+
],
|
|
211
|
+
current_run="20260101_120000",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# v2 experiment that failed
|
|
215
|
+
create_v2_experiment(
|
|
216
|
+
workspace,
|
|
217
|
+
"v2-failed",
|
|
218
|
+
runs=[
|
|
219
|
+
(
|
|
220
|
+
"20260101_110000",
|
|
221
|
+
"failed",
|
|
222
|
+
[
|
|
223
|
+
("pkg.FailTask", "fail-job-1", "error"),
|
|
224
|
+
],
|
|
225
|
+
),
|
|
226
|
+
],
|
|
227
|
+
current_run="20260101_110000",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
return workspace
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# =============================================================================
|
|
234
|
+
# Tests: Experiment Detection
|
|
235
|
+
# =============================================================================
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class TestGetExperiments:
|
|
239
|
+
"""Tests for get_experiments() method"""
|
|
240
|
+
|
|
241
|
+
def test_detects_v1_and_v2_experiments(self, mock_workspace):
|
|
242
|
+
"""Both v1 and v2 layout experiments should be detected"""
|
|
243
|
+
provider = WorkspaceStateProvider(mock_workspace)
|
|
244
|
+
experiments = provider.get_experiments()
|
|
245
|
+
|
|
246
|
+
exp_ids = {e.experiment_id for e in experiments}
|
|
247
|
+
assert "v1-mixed" in exp_ids, "v1 experiment not detected"
|
|
248
|
+
assert "v2-multi-run" in exp_ids, "v2 experiment not detected"
|
|
249
|
+
assert "v2-failed" in exp_ids, "v2 failed experiment not detected"
|
|
250
|
+
|
|
251
|
+
def test_experiment_stats_v1(self, mock_workspace):
|
|
252
|
+
"""v1 experiment should report correct job counts"""
|
|
253
|
+
provider = WorkspaceStateProvider(mock_workspace)
|
|
254
|
+
exp = provider.get_experiment("v1-mixed")
|
|
255
|
+
|
|
256
|
+
assert exp is not None
|
|
257
|
+
assert exp.total_jobs == 3
|
|
258
|
+
assert exp.finished_jobs == 2 # 2 done
|
|
259
|
+
assert exp.failed_jobs == 1 # 1 error
|
|
260
|
+
|
|
261
|
+
def test_experiment_stats_v2(self, mock_workspace):
|
|
262
|
+
"""v2 experiment should report correct job counts from current run"""
|
|
263
|
+
provider = WorkspaceStateProvider(mock_workspace)
|
|
264
|
+
exp = provider.get_experiment("v2-multi-run")
|
|
265
|
+
|
|
266
|
+
assert exp is not None
|
|
267
|
+
assert exp.run_id == "20260101_120000"
|
|
268
|
+
assert exp.total_jobs == 3
|
|
269
|
+
assert exp.finished_jobs == 1 # 1 done
|
|
270
|
+
assert exp.failed_jobs == 0
|
|
271
|
+
|
|
272
|
+
def test_failed_experiment_stats(self, mock_workspace):
|
|
273
|
+
"""Failed experiment should report error jobs"""
|
|
274
|
+
provider = WorkspaceStateProvider(mock_workspace)
|
|
275
|
+
exp = provider.get_experiment("v2-failed")
|
|
276
|
+
|
|
277
|
+
assert exp is not None
|
|
278
|
+
assert exp.total_jobs == 1
|
|
279
|
+
assert exp.failed_jobs == 1
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# =============================================================================
|
|
283
|
+
# Tests: Experiment Runs
|
|
284
|
+
# =============================================================================
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class TestGetExperimentRuns:
|
|
288
|
+
"""Tests for get_experiment_runs() method"""
|
|
289
|
+
|
|
290
|
+
def test_v2_multiple_runs(self, mock_workspace):
|
|
291
|
+
"""v2 experiment should return all runs"""
|
|
292
|
+
provider = WorkspaceStateProvider(mock_workspace)
|
|
293
|
+
runs = provider.get_experiment_runs("v2-multi-run")
|
|
294
|
+
|
|
295
|
+
assert len(runs) == 2
|
|
296
|
+
run_ids = {r.run_id for r in runs}
|
|
297
|
+
assert "20260101_100000" in run_ids
|
|
298
|
+
assert "20260101_120000" in run_ids
|
|
299
|
+
|
|
300
|
+
def test_v2_run_metadata(self, mock_workspace):
|
|
301
|
+
"""Run should contain correct metadata"""
|
|
302
|
+
from experimaestro.scheduler.interfaces import ExperimentStatus
|
|
303
|
+
|
|
304
|
+
provider = WorkspaceStateProvider(mock_workspace)
|
|
305
|
+
runs = provider.get_experiment_runs("v2-multi-run")
|
|
306
|
+
|
|
307
|
+
current_run = next(r for r in runs if r.run_id == "20260101_120000")
|
|
308
|
+
assert current_run.status == ExperimentStatus.RUNNING
|
|
309
|
+
assert current_run.hostname == "test-host"
|
|
310
|
+
assert current_run.total_jobs == 3
|
|
311
|
+
|
|
312
|
+
def test_v1_synthetic_run(self, mock_workspace):
|
|
313
|
+
"""v1 experiment should return synthetic 'v1' run"""
|
|
314
|
+
provider = WorkspaceStateProvider(mock_workspace)
|
|
315
|
+
runs = provider.get_experiment_runs("v1-mixed")
|
|
316
|
+
|
|
317
|
+
assert len(runs) == 1
|
|
318
|
+
assert runs[0].run_id == "v1"
|
|
319
|
+
assert runs[0].total_jobs == 3
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# =============================================================================
|
|
323
|
+
# Tests: Jobs
|
|
324
|
+
# =============================================================================
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class TestGetJobs:
|
|
328
|
+
"""Tests for get_jobs() method"""
|
|
329
|
+
|
|
330
|
+
def test_get_jobs_v2_current_run(self, mock_workspace):
|
|
331
|
+
"""Should return jobs from current run"""
|
|
332
|
+
provider = WorkspaceStateProvider(mock_workspace)
|
|
333
|
+
jobs = provider.get_jobs("v2-multi-run")
|
|
334
|
+
|
|
335
|
+
job_ids = {j.identifier for j in jobs}
|
|
336
|
+
assert "new-job-1" in job_ids
|
|
337
|
+
assert "new-job-2" in job_ids
|
|
338
|
+
assert "new-job-3" in job_ids
|
|
339
|
+
assert "old-job-1" not in job_ids # From older run
|
|
340
|
+
|
|
341
|
+
def test_get_jobs_specific_run(self, mock_workspace):
|
|
342
|
+
"""Should return jobs from specified run"""
|
|
343
|
+
provider = WorkspaceStateProvider(mock_workspace)
|
|
344
|
+
jobs = provider.get_jobs("v2-multi-run", run_id="20260101_100000")
|
|
345
|
+
|
|
346
|
+
job_ids = {j.identifier for j in jobs}
|
|
347
|
+
assert "old-job-1" in job_ids
|
|
348
|
+
assert "new-job-1" not in job_ids
|
|
349
|
+
|
|
350
|
+
def test_get_jobs_v1(self, mock_workspace):
|
|
351
|
+
"""Should return jobs from v1 experiment"""
|
|
352
|
+
provider = WorkspaceStateProvider(mock_workspace)
|
|
353
|
+
jobs = provider.get_jobs("v1-mixed")
|
|
354
|
+
|
|
355
|
+
job_ids = {j.identifier for j in jobs}
|
|
356
|
+
assert "job-a1" in job_ids
|
|
357
|
+
assert "job-b1" in job_ids
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# =============================================================================
|
|
361
|
+
# Tests: Event Detection (File Watcher)
|
|
362
|
+
# =============================================================================
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
class TestEventWatcher:
|
|
366
|
+
"""Tests for EventFileWatcher - event detection when files change"""
|
|
367
|
+
|
|
368
|
+
def test_detects_new_job_event(self, mock_workspace):
|
|
369
|
+
"""Should detect new events written to events-*.jsonl"""
|
|
370
|
+
provider = WorkspaceStateProvider(mock_workspace)
|
|
371
|
+
|
|
372
|
+
events_received = []
|
|
373
|
+
|
|
374
|
+
def listener(event):
|
|
375
|
+
events_received.append(event)
|
|
376
|
+
|
|
377
|
+
provider.add_listener(listener)
|
|
378
|
+
|
|
379
|
+
try:
|
|
380
|
+
# Create events file in new subdirectory format
|
|
381
|
+
exp_dir = mock_workspace / ".events" / "experiments" / "v2-multi-run"
|
|
382
|
+
exp_dir.mkdir(parents=True, exist_ok=True)
|
|
383
|
+
events_file = exp_dir / "events-1.jsonl"
|
|
384
|
+
|
|
385
|
+
# Write a job state change event
|
|
386
|
+
event_data = {
|
|
387
|
+
"event_type": "JobStateChangedEvent",
|
|
388
|
+
"job_id": "new-job-1",
|
|
389
|
+
"state": "done",
|
|
390
|
+
"timestamp": time.time(),
|
|
391
|
+
}
|
|
392
|
+
with open(events_file, "w") as f:
|
|
393
|
+
f.write(json.dumps(event_data) + "\n")
|
|
394
|
+
|
|
395
|
+
# Wait for watcher to pick up
|
|
396
|
+
time.sleep(1.0)
|
|
397
|
+
|
|
398
|
+
# Check that event was detected
|
|
399
|
+
job_events = [
|
|
400
|
+
e for e in events_received if isinstance(e, JobStateChangedEvent)
|
|
401
|
+
]
|
|
402
|
+
assert len(job_events) >= 1, f"Expected job event, got: {events_received}"
|
|
403
|
+
assert any(e.job_id == "new-job-1" for e in job_events)
|
|
404
|
+
finally:
|
|
405
|
+
provider.close()
|
|
406
|
+
|
|
407
|
+
def test_detects_multiple_events(self, mock_workspace):
|
|
408
|
+
"""Should detect multiple events appended to file"""
|
|
409
|
+
provider = WorkspaceStateProvider(mock_workspace)
|
|
410
|
+
|
|
411
|
+
events_received = []
|
|
412
|
+
provider.add_listener(lambda e: events_received.append(e))
|
|
413
|
+
|
|
414
|
+
try:
|
|
415
|
+
# Create events file in new subdirectory format
|
|
416
|
+
exp_dir = mock_workspace / ".events" / "experiments" / "v2-multi-run"
|
|
417
|
+
exp_dir.mkdir(parents=True, exist_ok=True)
|
|
418
|
+
events_file = exp_dir / "events-1.jsonl"
|
|
419
|
+
|
|
420
|
+
# Write first event
|
|
421
|
+
with open(events_file, "w") as f:
|
|
422
|
+
f.write(
|
|
423
|
+
json.dumps(
|
|
424
|
+
{
|
|
425
|
+
"event_type": "JobStateChangedEvent",
|
|
426
|
+
"job_id": "job-1",
|
|
427
|
+
"state": "running",
|
|
428
|
+
}
|
|
429
|
+
)
|
|
430
|
+
+ "\n"
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
time.sleep(0.7)
|
|
434
|
+
|
|
435
|
+
# Append second event
|
|
436
|
+
with open(events_file, "a") as f:
|
|
437
|
+
f.write(
|
|
438
|
+
json.dumps(
|
|
439
|
+
{
|
|
440
|
+
"event_type": "JobStateChangedEvent",
|
|
441
|
+
"job_id": "job-2",
|
|
442
|
+
"state": "done",
|
|
443
|
+
}
|
|
444
|
+
)
|
|
445
|
+
+ "\n"
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
time.sleep(0.7)
|
|
449
|
+
|
|
450
|
+
job_events = [
|
|
451
|
+
e for e in events_received if isinstance(e, JobStateChangedEvent)
|
|
452
|
+
]
|
|
453
|
+
job_ids = {e.job_id for e in job_events}
|
|
454
|
+
assert "job-1" in job_ids
|
|
455
|
+
assert "job-2" in job_ids
|
|
456
|
+
finally:
|
|
457
|
+
provider.close()
|
|
458
|
+
|
|
459
|
+
def test_detects_job_state_from_job_events(self, mock_workspace):
|
|
460
|
+
"""Should detect job state changes from job event files"""
|
|
461
|
+
provider = WorkspaceStateProvider(mock_workspace)
|
|
462
|
+
|
|
463
|
+
events_received = []
|
|
464
|
+
provider.add_listener(lambda e: events_received.append(e))
|
|
465
|
+
|
|
466
|
+
try:
|
|
467
|
+
# Create job events file in .events/jobs/{task_id}/event-{job_id}-{count}.jsonl
|
|
468
|
+
task_id = "my.test.task"
|
|
469
|
+
job_id = "test-job-123"
|
|
470
|
+
task_dir = mock_workspace / ".events" / "jobs" / task_id
|
|
471
|
+
task_dir.mkdir(parents=True, exist_ok=True)
|
|
472
|
+
events_file = task_dir / f"event-{job_id}-0.jsonl"
|
|
473
|
+
|
|
474
|
+
# Write job state changed event (from job process)
|
|
475
|
+
with open(events_file, "w") as f:
|
|
476
|
+
f.write(
|
|
477
|
+
json.dumps(
|
|
478
|
+
{
|
|
479
|
+
"event_type": "JobStateChangedEvent",
|
|
480
|
+
"job_id": job_id,
|
|
481
|
+
"state": "running",
|
|
482
|
+
"started_time": time.time(),
|
|
483
|
+
}
|
|
484
|
+
)
|
|
485
|
+
+ "\n"
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
time.sleep(1.0)
|
|
489
|
+
|
|
490
|
+
# Should have received job updated event
|
|
491
|
+
job_events = [
|
|
492
|
+
e for e in events_received if isinstance(e, JobStateChangedEvent)
|
|
493
|
+
]
|
|
494
|
+
assert len(job_events) >= 1, f"Expected job event, got: {events_received}"
|
|
495
|
+
assert any(e.job_id == job_id for e in job_events)
|
|
496
|
+
finally:
|
|
497
|
+
provider.close()
|
|
498
|
+
|
|
499
|
+
def test_detects_job_progress_from_job_events(self, mock_workspace):
|
|
500
|
+
"""Should detect job progress updates from job event files"""
|
|
501
|
+
provider = WorkspaceStateProvider(mock_workspace)
|
|
502
|
+
|
|
503
|
+
events_received = []
|
|
504
|
+
provider.add_listener(lambda e: events_received.append(e))
|
|
505
|
+
|
|
506
|
+
try:
|
|
507
|
+
# Create job events file in .events/jobs/{task_id}/event-{job_id}-{count}.jsonl
|
|
508
|
+
task_id = "my.progress.task"
|
|
509
|
+
job_id = "test-job-progress"
|
|
510
|
+
task_dir = mock_workspace / ".events" / "jobs" / task_id
|
|
511
|
+
task_dir.mkdir(parents=True, exist_ok=True)
|
|
512
|
+
events_file = task_dir / f"event-{job_id}-0.jsonl"
|
|
513
|
+
|
|
514
|
+
# Write job progress event
|
|
515
|
+
with open(events_file, "w") as f:
|
|
516
|
+
f.write(
|
|
517
|
+
json.dumps(
|
|
518
|
+
{
|
|
519
|
+
"event_type": "JobProgressEvent",
|
|
520
|
+
"job_id": job_id,
|
|
521
|
+
"level": 0,
|
|
522
|
+
"progress": 0.5,
|
|
523
|
+
"desc": "Halfway done",
|
|
524
|
+
}
|
|
525
|
+
)
|
|
526
|
+
+ "\n"
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
time.sleep(1.0)
|
|
530
|
+
|
|
531
|
+
# Should have received job progress event
|
|
532
|
+
job_events = [e for e in events_received if isinstance(e, JobProgressEvent)]
|
|
533
|
+
assert len(job_events) >= 1, (
|
|
534
|
+
f"Expected job progress event, got: {events_received}"
|
|
535
|
+
)
|
|
536
|
+
assert any(e.job_id == job_id for e in job_events)
|
|
537
|
+
finally:
|
|
538
|
+
provider.close()
|
|
539
|
+
|
|
540
|
+
def test_detects_experiment_finalization(self, mock_workspace):
|
|
541
|
+
"""Should detect when experiment finalizes (events file deleted)"""
|
|
542
|
+
provider = WorkspaceStateProvider(mock_workspace)
|
|
543
|
+
|
|
544
|
+
events_received = []
|
|
545
|
+
provider.add_listener(lambda e: events_received.append(e))
|
|
546
|
+
|
|
547
|
+
try:
|
|
548
|
+
# Create events file in new subdirectory format
|
|
549
|
+
exp_dir = mock_workspace / ".events" / "experiments" / "v2-multi-run"
|
|
550
|
+
exp_dir.mkdir(parents=True, exist_ok=True)
|
|
551
|
+
events_file = exp_dir / "events-1.jsonl"
|
|
552
|
+
|
|
553
|
+
# Create then delete events file (simulates finalization)
|
|
554
|
+
events_file.touch()
|
|
555
|
+
time.sleep(0.7)
|
|
556
|
+
events_file.unlink()
|
|
557
|
+
time.sleep(0.7)
|
|
558
|
+
|
|
559
|
+
# Should have received experiment updated event
|
|
560
|
+
exp_events = [
|
|
561
|
+
e for e in events_received if isinstance(e, ExperimentUpdatedEvent)
|
|
562
|
+
]
|
|
563
|
+
assert len(exp_events) >= 1
|
|
564
|
+
assert any(e.experiment_id == "v2-multi-run" for e in exp_events)
|
|
565
|
+
finally:
|
|
566
|
+
provider.close()
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
# =============================================================================
|
|
570
|
+
# Tests: Tags and Dependencies
|
|
571
|
+
# =============================================================================
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
class TestTagsAndDependencies:
|
|
575
|
+
"""Tests for get_tags_map() and get_dependencies_map()"""
|
|
576
|
+
|
|
577
|
+
def test_get_tags_map(self, mock_workspace):
|
|
578
|
+
"""Should return tags for jobs"""
|
|
579
|
+
provider = WorkspaceStateProvider(mock_workspace)
|
|
580
|
+
tags = provider.get_tags_map("v2-multi-run")
|
|
581
|
+
|
|
582
|
+
assert "new-job-1" in tags
|
|
583
|
+
assert tags["new-job-1"]["task"] == "NewTask"
|
|
584
|
+
|
|
585
|
+
def test_get_dependencies_map(self, mock_workspace):
|
|
586
|
+
"""Should return dependencies (empty in mock)"""
|
|
587
|
+
provider = WorkspaceStateProvider(mock_workspace)
|
|
588
|
+
deps = provider.get_dependencies_map("v2-multi-run")
|
|
589
|
+
|
|
590
|
+
# Our mock doesn't set dependencies, so should be empty
|
|
591
|
+
assert isinstance(deps, dict)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
# =============================================================================
|
|
595
|
+
# Tests: Orphan Job Detection
|
|
596
|
+
# =============================================================================
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def create_orphan_job(workspace: Path, task_id: str, job_id: str) -> Path:
|
|
600
|
+
"""Create an orphan job (not linked to any experiment)"""
|
|
601
|
+
job_path = workspace / "jobs" / task_id / job_id
|
|
602
|
+
job_path.mkdir(parents=True, exist_ok=True)
|
|
603
|
+
|
|
604
|
+
# Create a .done marker to indicate it's finished
|
|
605
|
+
scriptname = task_id.rsplit(".", 1)[-1]
|
|
606
|
+
(job_path / f"{scriptname}.done").touch()
|
|
607
|
+
|
|
608
|
+
return job_path
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
class TestOrphanJobDetection:
|
|
612
|
+
"""Tests for get_orphan_jobs() method"""
|
|
613
|
+
|
|
614
|
+
def test_no_orphans_in_empty_workspace(self, tmp_path):
|
|
615
|
+
"""Empty workspace should have no orphans"""
|
|
616
|
+
workspace = tmp_path / "workspace"
|
|
617
|
+
workspace.mkdir()
|
|
618
|
+
|
|
619
|
+
provider = WorkspaceStateProvider(workspace)
|
|
620
|
+
orphans = provider.get_orphan_jobs()
|
|
621
|
+
|
|
622
|
+
assert len(orphans) == 0
|
|
623
|
+
|
|
624
|
+
def test_v1_jobs_not_detected_as_orphans(self, mock_workspace):
|
|
625
|
+
"""Jobs in v1 experiments should NOT be detected as orphans"""
|
|
626
|
+
provider = WorkspaceStateProvider(mock_workspace)
|
|
627
|
+
orphans = provider.get_orphan_jobs()
|
|
628
|
+
|
|
629
|
+
# The mock_workspace has v1-mixed experiment with jobs:
|
|
630
|
+
# job-a1, job-a2 (TaskA), job-b1 (TaskB)
|
|
631
|
+
# These should NOT be in orphans
|
|
632
|
+
orphan_ids = {j.identifier for j in orphans}
|
|
633
|
+
|
|
634
|
+
assert "job-a1" not in orphan_ids, "v1 job-a1 incorrectly detected as orphan"
|
|
635
|
+
assert "job-a2" not in orphan_ids, "v1 job-a2 incorrectly detected as orphan"
|
|
636
|
+
assert "job-b1" not in orphan_ids, "v1 job-b1 incorrectly detected as orphan"
|
|
637
|
+
|
|
638
|
+
def test_v2_jobs_not_detected_as_orphans(self, mock_workspace):
|
|
639
|
+
"""Jobs in v2 experiments should NOT be detected as orphans"""
|
|
640
|
+
provider = WorkspaceStateProvider(mock_workspace)
|
|
641
|
+
orphans = provider.get_orphan_jobs()
|
|
642
|
+
|
|
643
|
+
# The mock_workspace has v2-multi-run experiment with jobs in status.json
|
|
644
|
+
# These should NOT be in orphans
|
|
645
|
+
orphan_ids = {j.identifier for j in orphans}
|
|
646
|
+
|
|
647
|
+
# Jobs from v2-multi-run (both runs)
|
|
648
|
+
assert "old-job-1" not in orphan_ids, (
|
|
649
|
+
"v2 old-job-1 incorrectly detected as orphan"
|
|
650
|
+
)
|
|
651
|
+
assert "new-job-1" not in orphan_ids, (
|
|
652
|
+
"v2 new-job-1 incorrectly detected as orphan"
|
|
653
|
+
)
|
|
654
|
+
assert "new-job-2" not in orphan_ids, (
|
|
655
|
+
"v2 new-job-2 incorrectly detected as orphan"
|
|
656
|
+
)
|
|
657
|
+
assert "new-job-3" not in orphan_ids, (
|
|
658
|
+
"v2 new-job-3 incorrectly detected as orphan"
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
# Jobs from v2-failed
|
|
662
|
+
assert "fail-job-1" not in orphan_ids, (
|
|
663
|
+
"v2 fail-job-1 incorrectly detected as orphan"
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
def test_orphan_job_is_detected(self, mock_workspace):
|
|
667
|
+
"""Jobs not in any experiment should be detected as orphans"""
|
|
668
|
+
# Create an orphan job
|
|
669
|
+
orphan_path = create_orphan_job(
|
|
670
|
+
mock_workspace, "pkg.OrphanTask", "orphan-job-1"
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
provider = WorkspaceStateProvider(mock_workspace)
|
|
674
|
+
orphans = provider.get_orphan_jobs()
|
|
675
|
+
|
|
676
|
+
orphan_ids = {j.identifier for j in orphans}
|
|
677
|
+
assert "orphan-job-1" in orphan_ids, "Orphan job not detected"
|
|
678
|
+
|
|
679
|
+
# Verify the orphan has correct properties
|
|
680
|
+
orphan = next(j for j in orphans if j.identifier == "orphan-job-1")
|
|
681
|
+
assert orphan.task_id == "pkg.OrphanTask"
|
|
682
|
+
assert orphan.path == orphan_path
|
|
683
|
+
|
|
684
|
+
def test_multiple_orphans_detected(self, mock_workspace):
|
|
685
|
+
"""Multiple orphan jobs should all be detected"""
|
|
686
|
+
# Create multiple orphan jobs
|
|
687
|
+
create_orphan_job(mock_workspace, "pkg.Task1", "orphan-1")
|
|
688
|
+
create_orphan_job(mock_workspace, "pkg.Task2", "orphan-2")
|
|
689
|
+
create_orphan_job(mock_workspace, "pkg.Task2", "orphan-3")
|
|
690
|
+
|
|
691
|
+
provider = WorkspaceStateProvider(mock_workspace)
|
|
692
|
+
orphans = provider.get_orphan_jobs()
|
|
693
|
+
|
|
694
|
+
orphan_ids = {j.identifier for j in orphans}
|
|
695
|
+
assert "orphan-1" in orphan_ids
|
|
696
|
+
assert "orphan-2" in orphan_ids
|
|
697
|
+
assert "orphan-3" in orphan_ids
|
|
698
|
+
|
|
699
|
+
def test_experiment_jobs_never_detected_as_orphans(self, mock_workspace):
|
|
700
|
+
"""All jobs that exist in experiments should never be orphans"""
|
|
701
|
+
provider = WorkspaceStateProvider(mock_workspace)
|
|
702
|
+
|
|
703
|
+
# Get all jobs from all experiments
|
|
704
|
+
all_experiment_jobs = set()
|
|
705
|
+
|
|
706
|
+
# v1 experiment jobs
|
|
707
|
+
v1_jobs = provider.get_jobs("v1-mixed")
|
|
708
|
+
for j in v1_jobs:
|
|
709
|
+
all_experiment_jobs.add(j.identifier)
|
|
710
|
+
|
|
711
|
+
# v2 experiment jobs (all runs)
|
|
712
|
+
for run in provider.get_experiment_runs("v2-multi-run"):
|
|
713
|
+
jobs = provider.get_jobs("v2-multi-run", run_id=run.run_id)
|
|
714
|
+
for j in jobs:
|
|
715
|
+
all_experiment_jobs.add(j.identifier)
|
|
716
|
+
|
|
717
|
+
# v2 failed experiment jobs
|
|
718
|
+
for run in provider.get_experiment_runs("v2-failed"):
|
|
719
|
+
jobs = provider.get_jobs("v2-failed", run_id=run.run_id)
|
|
720
|
+
for j in jobs:
|
|
721
|
+
all_experiment_jobs.add(j.identifier)
|
|
722
|
+
|
|
723
|
+
# Get orphans
|
|
724
|
+
orphans = provider.get_orphan_jobs()
|
|
725
|
+
orphan_ids = {j.identifier for j in orphans}
|
|
726
|
+
|
|
727
|
+
# Verify no experiment job is in orphans
|
|
728
|
+
intersection = all_experiment_jobs & orphan_ids
|
|
729
|
+
assert len(intersection) == 0, (
|
|
730
|
+
f"Experiment jobs incorrectly detected as orphans: {intersection}"
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
def test_orphan_with_v1_and_v2_mixed(self, tmp_path):
|
|
734
|
+
"""Test orphan detection with both v1 and v2 experiments present"""
|
|
735
|
+
workspace = tmp_path / "workspace"
|
|
736
|
+
workspace.mkdir()
|
|
737
|
+
|
|
738
|
+
# Create v1 experiment with one job
|
|
739
|
+
create_v1_experiment(
|
|
740
|
+
workspace,
|
|
741
|
+
"v1-exp",
|
|
742
|
+
jobs=[("pkg.TaskA", "v1-job", "done")],
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
# Create v2 experiment with one job
|
|
746
|
+
create_v2_experiment(
|
|
747
|
+
workspace,
|
|
748
|
+
"v2-exp",
|
|
749
|
+
runs=[
|
|
750
|
+
("20260101_100000", "completed", [("pkg.TaskB", "v2-job", "done")]),
|
|
751
|
+
],
|
|
752
|
+
current_run="20260101_100000",
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
# Create two orphan jobs
|
|
756
|
+
create_orphan_job(workspace, "pkg.TaskC", "orphan-1")
|
|
757
|
+
create_orphan_job(workspace, "pkg.TaskD", "orphan-2")
|
|
758
|
+
|
|
759
|
+
provider = WorkspaceStateProvider(workspace)
|
|
760
|
+
orphans = provider.get_orphan_jobs()
|
|
761
|
+
orphan_ids = {j.identifier for j in orphans}
|
|
762
|
+
|
|
763
|
+
# Only the orphans should be detected
|
|
764
|
+
assert len(orphans) == 2
|
|
765
|
+
assert "orphan-1" in orphan_ids
|
|
766
|
+
assert "orphan-2" in orphan_ids
|
|
767
|
+
assert "v1-job" not in orphan_ids
|
|
768
|
+
assert "v2-job" not in orphan_ids
|