lemming-cli 0.1.0__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.
- lemming/__init__.py +1 -0
- lemming/api/__init__.py +5 -0
- lemming/api/auth.py +34 -0
- lemming/api/auth_test.py +40 -0
- lemming/api/config.py +85 -0
- lemming/api/config_test.py +84 -0
- lemming/api/conftest.py +204 -0
- lemming/api/context.py +39 -0
- lemming/api/context_test.py +62 -0
- lemming/api/directories.py +57 -0
- lemming/api/directories_test.py +94 -0
- lemming/api/files.py +116 -0
- lemming/api/files_test.py +163 -0
- lemming/api/hooks.py +15 -0
- lemming/api/hooks_test.py +33 -0
- lemming/api/logging.py +16 -0
- lemming/api/logging_test.py +59 -0
- lemming/api/loop.py +45 -0
- lemming/api/loop_test.py +58 -0
- lemming/api/main.py +53 -0
- lemming/api/main_test.py +30 -0
- lemming/api/tasks.py +170 -0
- lemming/api/tasks_test.py +426 -0
- lemming/api.py +5 -0
- lemming/api_test.py +7 -0
- lemming/cli/__init__.py +12 -0
- lemming/cli/config.py +67 -0
- lemming/cli/config_test.py +50 -0
- lemming/cli/context.py +40 -0
- lemming/cli/context_test.py +49 -0
- lemming/cli/hooks.py +147 -0
- lemming/cli/hooks_test.py +50 -0
- lemming/cli/main.py +42 -0
- lemming/cli/main_test.py +21 -0
- lemming/cli/operations.py +226 -0
- lemming/cli/operations_test.py +44 -0
- lemming/cli/progress.py +54 -0
- lemming/cli/progress_test.py +40 -0
- lemming/cli/readability_cli.py +28 -0
- lemming/cli/readability_cli_test.py +57 -0
- lemming/cli/tasks.py +529 -0
- lemming/cli/tasks_test.py +168 -0
- lemming/cli.py +5 -0
- lemming/cli_test.py +22 -0
- lemming/conftest.py +13 -0
- lemming/hooks.py +145 -0
- lemming/hooks_test.py +180 -0
- lemming/integration_test.py +299 -0
- lemming/main.py +6 -0
- lemming/main_test.py +44 -0
- lemming/models.py +88 -0
- lemming/models_test.py +91 -0
- lemming/orchestrator.py +407 -0
- lemming/orchestrator_test.py +468 -0
- lemming/paths.py +245 -0
- lemming/paths_test.py +186 -0
- lemming/persistence.py +179 -0
- lemming/persistence_test.py +150 -0
- lemming/prompts/hooks/readability.md +42 -0
- lemming/prompts/hooks/roadmap.md +61 -0
- lemming/prompts/hooks/testing.md +44 -0
- lemming/prompts/taskrunner.md +69 -0
- lemming/prompts.py +354 -0
- lemming/prompts_test.py +421 -0
- lemming/providers.py +190 -0
- lemming/providers_test.py +73 -0
- lemming/runner.py +327 -0
- lemming/runner_test.py +378 -0
- lemming/tasks/__init__.py +75 -0
- lemming/tasks/lifecycle.py +331 -0
- lemming/tasks/lifecycle_test.py +312 -0
- lemming/tasks/operations.py +258 -0
- lemming/tasks/operations_test.py +172 -0
- lemming/tasks/progress.py +29 -0
- lemming/tasks/progress_test.py +22 -0
- lemming/tasks/queries.py +128 -0
- lemming/tasks/queries_test.py +233 -0
- lemming/web/dashboard.spec.js +350 -0
- lemming/web/dashboard.test.js +998 -0
- lemming/web/favicon.js +40 -0
- lemming/web/favicon.spec.js +242 -0
- lemming/web/files.html +375 -0
- lemming/web/files.spec.js +97 -0
- lemming/web/index.html +983 -0
- lemming/web/index.js +753 -0
- lemming/web/logs.html +358 -0
- lemming/web/logs.test.js +195 -0
- lemming/web/mancha.js +58 -0
- lemming/web/screenshots.spec.js +328 -0
- lemming_cli-0.1.0.dist-info/METADATA +314 -0
- lemming_cli-0.1.0.dist-info/RECORD +94 -0
- lemming_cli-0.1.0.dist-info/WHEEL +4 -0
- lemming_cli-0.1.0.dist-info/entry_points.txt +2 -0
- lemming_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Task management package: lifecycle, operations, progress, and queries."""
|
|
2
|
+
|
|
3
|
+
from .. import models, persistence
|
|
4
|
+
from .lifecycle import (
|
|
5
|
+
STALE_THRESHOLD as STALE_THRESHOLD,
|
|
6
|
+
)
|
|
7
|
+
from .lifecycle import (
|
|
8
|
+
cancel_task as cancel_task,
|
|
9
|
+
)
|
|
10
|
+
from .lifecycle import (
|
|
11
|
+
claim_task as claim_task,
|
|
12
|
+
)
|
|
13
|
+
from .lifecycle import (
|
|
14
|
+
finish_task_attempt as finish_task_attempt,
|
|
15
|
+
)
|
|
16
|
+
from .lifecycle import (
|
|
17
|
+
generate_task_id as generate_task_id,
|
|
18
|
+
)
|
|
19
|
+
from .lifecycle import (
|
|
20
|
+
is_loop_running as is_loop_running,
|
|
21
|
+
)
|
|
22
|
+
from .lifecycle import (
|
|
23
|
+
is_pid_alive as is_pid_alive,
|
|
24
|
+
)
|
|
25
|
+
from .lifecycle import (
|
|
26
|
+
mark_task_in_progress as mark_task_in_progress,
|
|
27
|
+
)
|
|
28
|
+
from .lifecycle import (
|
|
29
|
+
reset_task as reset_task,
|
|
30
|
+
)
|
|
31
|
+
from .lifecycle import (
|
|
32
|
+
reset_task_logs as reset_task_logs,
|
|
33
|
+
)
|
|
34
|
+
from .lifecycle import (
|
|
35
|
+
update_heartbeat as update_heartbeat,
|
|
36
|
+
)
|
|
37
|
+
from .lifecycle import (
|
|
38
|
+
update_run_time as update_run_time,
|
|
39
|
+
)
|
|
40
|
+
from .operations import (
|
|
41
|
+
add_task as add_task,
|
|
42
|
+
)
|
|
43
|
+
from .operations import (
|
|
44
|
+
delete_tasks as delete_tasks,
|
|
45
|
+
)
|
|
46
|
+
from .operations import (
|
|
47
|
+
update_context as update_context,
|
|
48
|
+
)
|
|
49
|
+
from .operations import (
|
|
50
|
+
update_task as update_task,
|
|
51
|
+
)
|
|
52
|
+
from .progress import add_progress as add_progress
|
|
53
|
+
from .queries import (
|
|
54
|
+
get_pending_task as get_pending_task,
|
|
55
|
+
)
|
|
56
|
+
from .queries import (
|
|
57
|
+
get_project_data as get_project_data,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Re-export persistence functions for backward compatibility
|
|
61
|
+
save_tasks = persistence.save_tasks
|
|
62
|
+
load_tasks = persistence.load_tasks
|
|
63
|
+
lock_tasks = persistence.lock_tasks
|
|
64
|
+
acquire_loop_lock = persistence.acquire_loop_lock
|
|
65
|
+
release_loop_lock = persistence.release_loop_lock
|
|
66
|
+
get_loop_pid = persistence.get_loop_pid
|
|
67
|
+
LOOP_LOCK_FILENAME = persistence.LOOP_LOCK_FILENAME
|
|
68
|
+
|
|
69
|
+
# Re-export models for compatibility
|
|
70
|
+
TaskNotFoundError = models.TaskNotFoundError
|
|
71
|
+
Task = models.Task
|
|
72
|
+
TaskStatus = models.TaskStatus
|
|
73
|
+
ProjectData = models.ProjectData
|
|
74
|
+
RoadmapConfig = models.RoadmapConfig
|
|
75
|
+
Roadmap = models.Roadmap
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Task lifecycle management: claiming, heartbeats, cancellation, resets."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import pathlib
|
|
5
|
+
import secrets
|
|
6
|
+
import signal
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from .. import models, paths, persistence
|
|
10
|
+
|
|
11
|
+
# Re-export constants for internal/external use
|
|
12
|
+
STALE_THRESHOLD = persistence.STALE_THRESHOLD
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def generate_task_id() -> str:
|
|
16
|
+
"""Generates a random short hex string for a unique task ID.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
A random 8-character hex string.
|
|
20
|
+
"""
|
|
21
|
+
return secrets.token_hex(4)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_pid_alive(pid: int) -> bool:
|
|
25
|
+
"""Check if a process is still running.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
pid: The process ID to check.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
True if the process is alive, False otherwise.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
os.kill(pid, 0)
|
|
35
|
+
except OSError:
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
# Check for zombie state on Linux
|
|
39
|
+
try:
|
|
40
|
+
status_path = pathlib.Path(f"/proc/{pid}/status")
|
|
41
|
+
if status_path.exists():
|
|
42
|
+
for line in status_path.read_text().splitlines():
|
|
43
|
+
if line.startswith("State:"):
|
|
44
|
+
state = line.split()[1]
|
|
45
|
+
if state == "Z":
|
|
46
|
+
return False
|
|
47
|
+
except OSError:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_loop_running(tasks_file: pathlib.Path) -> bool:
|
|
54
|
+
"""Check if an orchestrator loop is actively running."""
|
|
55
|
+
pid = persistence.get_loop_pid(tasks_file)
|
|
56
|
+
return pid is not None and is_pid_alive(pid)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def update_run_time(task: models.Task, end_time: float | None = None) -> None:
|
|
60
|
+
"""Accumulates the execution run time for a given task.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
task: The Task to update.
|
|
64
|
+
end_time: Optional end timestamp (defaults to current time).
|
|
65
|
+
"""
|
|
66
|
+
if task.last_started_at is not None:
|
|
67
|
+
end = end_time or time.time()
|
|
68
|
+
duration = end - task.last_started_at
|
|
69
|
+
task.run_time += duration
|
|
70
|
+
task.last_started_at = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_task_active(task: models.Task, now: float) -> bool:
|
|
74
|
+
"""Returns True if the task is actively being executed.
|
|
75
|
+
|
|
76
|
+
A task is active if:
|
|
77
|
+
1. It is marked IN_PROGRESS or has a requested_status (finalizing).
|
|
78
|
+
2. AND it has a PID that is alive.
|
|
79
|
+
3. AND its heartbeat is recent (not stale).
|
|
80
|
+
"""
|
|
81
|
+
if (
|
|
82
|
+
task.status != models.TaskStatus.IN_PROGRESS
|
|
83
|
+
and not task.requested_status
|
|
84
|
+
):
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
if not task.pid or not is_pid_alive(task.pid):
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
last_heartbeat = task.last_heartbeat or 0
|
|
91
|
+
if now - last_heartbeat > STALE_THRESHOLD:
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _mark_task_in_progress(
|
|
98
|
+
data: models.Roadmap, task_id: str, pid: int | None = None
|
|
99
|
+
) -> bool:
|
|
100
|
+
"""Internal helper to mark a task as in_progress without locking or saving.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
data: The Roadmap to update.
|
|
104
|
+
task_id: The ID of the task to mark.
|
|
105
|
+
pid: Optional process ID associated with the task.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
True if the task was successfully marked, False otherwise.
|
|
109
|
+
"""
|
|
110
|
+
now = time.time()
|
|
111
|
+
for task in data.tasks:
|
|
112
|
+
if task.id == task_id:
|
|
113
|
+
# A task can be marked in progress if it's pending, or if it's
|
|
114
|
+
# nominally in progress/finalizing but has crashed or timed out
|
|
115
|
+
# (stale).
|
|
116
|
+
if task.status == models.TaskStatus.PENDING or (
|
|
117
|
+
(
|
|
118
|
+
task.status == models.TaskStatus.IN_PROGRESS
|
|
119
|
+
or task.requested_status
|
|
120
|
+
)
|
|
121
|
+
and not is_task_active(task, now)
|
|
122
|
+
):
|
|
123
|
+
task.status = models.TaskStatus.IN_PROGRESS
|
|
124
|
+
task.last_heartbeat = now
|
|
125
|
+
if task.started_at is None:
|
|
126
|
+
task.started_at = now
|
|
127
|
+
task.last_started_at = now
|
|
128
|
+
if pid is not None:
|
|
129
|
+
task.pid = pid
|
|
130
|
+
return True
|
|
131
|
+
return False
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def mark_task_in_progress(
|
|
136
|
+
tasks_file: pathlib.Path, task_id: str, pid: int | None = None
|
|
137
|
+
) -> bool:
|
|
138
|
+
"""Try to mark a task as in_progress. Returns True if successful.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
tasks_file: Path to the tasks YAML file.
|
|
142
|
+
task_id: The ID of the task to mark.
|
|
143
|
+
pid: Optional process ID associated with the task.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
True if the task was successfully marked, False otherwise.
|
|
147
|
+
"""
|
|
148
|
+
with persistence.lock_tasks(tasks_file):
|
|
149
|
+
data = persistence.load_tasks(tasks_file)
|
|
150
|
+
if _mark_task_in_progress(data, task_id, pid=pid):
|
|
151
|
+
persistence.save_tasks(tasks_file, data)
|
|
152
|
+
return True
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def claim_task(
|
|
157
|
+
tasks_file: pathlib.Path, task_id: str, pid: int
|
|
158
|
+
) -> models.Task | None:
|
|
159
|
+
"""Claims a task for execution: marks in_progress and increments attempts.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
tasks_file: Path to the tasks YAML file.
|
|
163
|
+
task_id: The ID of the task to claim.
|
|
164
|
+
pid: The process ID of the agent executing the task.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
The claimed Task, or None if the task could not be claimed.
|
|
168
|
+
"""
|
|
169
|
+
with persistence.lock_tasks(tasks_file):
|
|
170
|
+
data = persistence.load_tasks(tasks_file)
|
|
171
|
+
if not _mark_task_in_progress(data, task_id, pid=pid):
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
task = next(t for t in data.tasks if t.id == task_id)
|
|
175
|
+
if not task.requested_status:
|
|
176
|
+
task.attempts += 1
|
|
177
|
+
persistence.save_tasks(tasks_file, data)
|
|
178
|
+
return task
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def finish_task_attempt(
|
|
182
|
+
tasks_file: pathlib.Path, task_id: str
|
|
183
|
+
) -> models.Task | None:
|
|
184
|
+
"""Handles post-execution cleanup for a task attempt.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
tasks_file: Path to the tasks YAML file.
|
|
188
|
+
task_id: The ID of the task that finished.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
The updated Task, or None if not found.
|
|
192
|
+
"""
|
|
193
|
+
with persistence.lock_tasks(tasks_file):
|
|
194
|
+
data = persistence.load_tasks(tasks_file)
|
|
195
|
+
task = next((t for t in data.tasks if t.id == task_id), None)
|
|
196
|
+
if not task:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
if task.status == models.TaskStatus.IN_PROGRESS:
|
|
200
|
+
# If it failed or simply finished without requesting completion,
|
|
201
|
+
# we keep it as pending so it can be retried.
|
|
202
|
+
if not task.requested_status:
|
|
203
|
+
update_run_time(task)
|
|
204
|
+
task.status = models.TaskStatus.PENDING
|
|
205
|
+
task.last_heartbeat = None
|
|
206
|
+
else:
|
|
207
|
+
task.last_heartbeat = time.time()
|
|
208
|
+
|
|
209
|
+
task.pid = None
|
|
210
|
+
persistence.save_tasks(tasks_file, data)
|
|
211
|
+
|
|
212
|
+
return task
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def update_heartbeat(
|
|
216
|
+
tasks_file: pathlib.Path, task_id: str, pid: int | None = None
|
|
217
|
+
) -> bool:
|
|
218
|
+
"""Updates the heartbeat timestamp for a task.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
tasks_file: Path to the tasks YAML file.
|
|
222
|
+
task_id: The ID of the task to update.
|
|
223
|
+
pid: Optional new PID to store (e.g. the runner subprocess PID).
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
True if the task is still in_progress, False if it was cancelled or
|
|
227
|
+
otherwise changed (caller should stop work).
|
|
228
|
+
"""
|
|
229
|
+
with persistence.lock_tasks(tasks_file):
|
|
230
|
+
data = persistence.load_tasks(tasks_file)
|
|
231
|
+
for task in data.tasks:
|
|
232
|
+
if task.id == task_id:
|
|
233
|
+
if task.status != models.TaskStatus.IN_PROGRESS:
|
|
234
|
+
return False
|
|
235
|
+
task.last_heartbeat = time.time()
|
|
236
|
+
if pid is not None:
|
|
237
|
+
task.pid = pid
|
|
238
|
+
break
|
|
239
|
+
persistence.save_tasks(tasks_file, data)
|
|
240
|
+
return True
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def reset_task_logs(tasks_file: pathlib.Path, task_id: str) -> None:
|
|
244
|
+
"""Deletes the runner log for a given task.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
tasks_file: Path to the tasks YAML file.
|
|
248
|
+
task_id: The ID of the task whose logs should be cleared.
|
|
249
|
+
"""
|
|
250
|
+
log_file = paths.get_log_file(tasks_file, task_id)
|
|
251
|
+
if log_file.exists():
|
|
252
|
+
log_file.unlink()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def cancel_task(tasks_file: pathlib.Path, task_id: str) -> bool:
|
|
256
|
+
"""Kill the task process AND the orchestrator loop, then mark cancelled.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
tasks_file: Path to the tasks YAML file.
|
|
260
|
+
task_id: The ID of the task to cancel.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
True if the task was found and cancellation was attempted, False
|
|
264
|
+
otherwise.
|
|
265
|
+
"""
|
|
266
|
+
with persistence.lock_tasks(tasks_file):
|
|
267
|
+
data = persistence.load_tasks(tasks_file)
|
|
268
|
+
|
|
269
|
+
for task in data.tasks:
|
|
270
|
+
if task.id == task_id:
|
|
271
|
+
# First kill the task itself
|
|
272
|
+
if task.pid:
|
|
273
|
+
try:
|
|
274
|
+
# Try to kill the whole process group if possible
|
|
275
|
+
os.killpg(os.getpgid(task.pid), signal.SIGTERM)
|
|
276
|
+
except OSError:
|
|
277
|
+
try:
|
|
278
|
+
os.kill(task.pid, signal.SIGTERM)
|
|
279
|
+
except OSError:
|
|
280
|
+
pass
|
|
281
|
+
|
|
282
|
+
# Also kill the orchestrator loop if it's running
|
|
283
|
+
loop_pid = persistence.get_loop_pid(tasks_file)
|
|
284
|
+
if loop_pid:
|
|
285
|
+
try:
|
|
286
|
+
os.kill(loop_pid, signal.SIGTERM)
|
|
287
|
+
except OSError:
|
|
288
|
+
pass
|
|
289
|
+
|
|
290
|
+
update_run_time(task)
|
|
291
|
+
task.status = models.TaskStatus.CANCELLED
|
|
292
|
+
task.pid = None
|
|
293
|
+
task.last_heartbeat = None
|
|
294
|
+
task.requested_status = None
|
|
295
|
+
|
|
296
|
+
persistence.save_tasks(tasks_file, data)
|
|
297
|
+
return True
|
|
298
|
+
return False
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def reset_task(tasks_file: pathlib.Path, task_id: str) -> models.Task:
|
|
302
|
+
"""Resets a task's attempts and progress.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
tasks_file: Path to the tasks YAML file.
|
|
306
|
+
task_id: ID of the task to reset.
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
The reset Task.
|
|
310
|
+
"""
|
|
311
|
+
with persistence.lock_tasks(tasks_file):
|
|
312
|
+
data = persistence.load_tasks(tasks_file)
|
|
313
|
+
target = next((t for t in data.tasks if t.id.startswith(task_id)), None)
|
|
314
|
+
if not target:
|
|
315
|
+
raise models.TaskNotFoundError(f"Task {task_id} not found")
|
|
316
|
+
|
|
317
|
+
target.status = models.TaskStatus.PENDING
|
|
318
|
+
target.attempts = 0
|
|
319
|
+
target.progress = []
|
|
320
|
+
target.run_time = 0.0
|
|
321
|
+
target.completed_at = None
|
|
322
|
+
target.started_at = None
|
|
323
|
+
target.last_started_at = None
|
|
324
|
+
target.pid = None
|
|
325
|
+
target.last_heartbeat = None
|
|
326
|
+
target.requested_status = None
|
|
327
|
+
|
|
328
|
+
persistence.save_tasks(tasks_file, data)
|
|
329
|
+
|
|
330
|
+
reset_task_logs(tasks_file, target.id)
|
|
331
|
+
return target
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import signal
|
|
3
|
+
import time
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
from lemming import paths
|
|
7
|
+
|
|
8
|
+
from .. import models, persistence
|
|
9
|
+
from . import lifecycle
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_generate_task_id():
|
|
13
|
+
id1 = lifecycle.generate_task_id()
|
|
14
|
+
id2 = lifecycle.generate_task_id()
|
|
15
|
+
assert len(id1) == 8
|
|
16
|
+
assert id1 != id2
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_is_pid_alive():
|
|
20
|
+
assert lifecycle.is_pid_alive(os.getpid()) is True
|
|
21
|
+
assert lifecycle.is_pid_alive(999999) is False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_is_loop_running_stale_pid(tmp_path):
|
|
25
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
26
|
+
persistence.acquire_loop_lock(tasks_file)
|
|
27
|
+
assert lifecycle.is_loop_running(tasks_file) is True
|
|
28
|
+
|
|
29
|
+
# Manually overwrite with stale PID
|
|
30
|
+
lock_path = (
|
|
31
|
+
paths.get_project_dir(tasks_file) / persistence.LOOP_LOCK_FILENAME
|
|
32
|
+
)
|
|
33
|
+
lock_path.write_text("999999")
|
|
34
|
+
assert lifecycle.is_loop_running(tasks_file) is False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_update_run_time():
|
|
38
|
+
task = models.Task(
|
|
39
|
+
id="1", description="test", last_started_at=time.time() - 5
|
|
40
|
+
)
|
|
41
|
+
lifecycle.update_run_time(task)
|
|
42
|
+
assert task.run_time >= 5.0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_mark_task_in_progress(tmp_path):
|
|
46
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
47
|
+
data = models.Roadmap(
|
|
48
|
+
tasks=[
|
|
49
|
+
models.Task(
|
|
50
|
+
id="1", description="Task 1", status=models.TaskStatus.PENDING
|
|
51
|
+
)
|
|
52
|
+
]
|
|
53
|
+
)
|
|
54
|
+
persistence.save_tasks(tasks_file, data)
|
|
55
|
+
|
|
56
|
+
success = lifecycle.mark_task_in_progress(tasks_file, "1", pid=123)
|
|
57
|
+
assert success is True
|
|
58
|
+
|
|
59
|
+
updated_data = persistence.load_tasks(tasks_file)
|
|
60
|
+
assert updated_data.tasks[0].status == models.TaskStatus.IN_PROGRESS
|
|
61
|
+
assert updated_data.tasks[0].pid == 123
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_claim_task(tmp_path):
|
|
65
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
66
|
+
data = models.Roadmap(
|
|
67
|
+
tasks=[
|
|
68
|
+
models.Task(
|
|
69
|
+
id="1", description="Task 1", status=models.TaskStatus.PENDING
|
|
70
|
+
)
|
|
71
|
+
]
|
|
72
|
+
)
|
|
73
|
+
persistence.save_tasks(tasks_file, data)
|
|
74
|
+
|
|
75
|
+
claimed = lifecycle.claim_task(tasks_file, "1", pid=123)
|
|
76
|
+
assert claimed is not None
|
|
77
|
+
assert claimed.status == models.TaskStatus.IN_PROGRESS
|
|
78
|
+
assert claimed.pid == 123
|
|
79
|
+
assert claimed.attempts == 1
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_claim_already_in_progress(tmp_path):
|
|
83
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
84
|
+
data = models.Roadmap(
|
|
85
|
+
tasks=[
|
|
86
|
+
models.Task(
|
|
87
|
+
id="1",
|
|
88
|
+
description="Task 1",
|
|
89
|
+
status=models.TaskStatus.IN_PROGRESS,
|
|
90
|
+
pid=os.getpid(),
|
|
91
|
+
last_heartbeat=time.time(),
|
|
92
|
+
)
|
|
93
|
+
]
|
|
94
|
+
)
|
|
95
|
+
persistence.save_tasks(tasks_file, data)
|
|
96
|
+
|
|
97
|
+
# Second claim should fail
|
|
98
|
+
claimed_again = lifecycle.claim_task(tasks_file, "1", pid=456)
|
|
99
|
+
assert claimed_again is None
|
|
100
|
+
|
|
101
|
+
# But if it's stale, it should succeed
|
|
102
|
+
with persistence.lock_tasks(tasks_file):
|
|
103
|
+
data = persistence.load_tasks(tasks_file)
|
|
104
|
+
data.tasks[0].last_heartbeat = time.time() - (
|
|
105
|
+
persistence.STALE_THRESHOLD + 1
|
|
106
|
+
)
|
|
107
|
+
persistence.save_tasks(tasks_file, data)
|
|
108
|
+
|
|
109
|
+
claimed_stale = lifecycle.claim_task(tasks_file, "1", pid=789)
|
|
110
|
+
assert claimed_stale is not None
|
|
111
|
+
assert claimed_stale.pid == 789
|
|
112
|
+
assert (
|
|
113
|
+
claimed_stale.attempts == 1
|
|
114
|
+
) # 0 + 1 because we manually created it with 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_finish_task_attempt(tmp_path):
|
|
118
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
119
|
+
data = models.Roadmap(
|
|
120
|
+
tasks=[
|
|
121
|
+
models.Task(
|
|
122
|
+
id="1",
|
|
123
|
+
description="Task 1",
|
|
124
|
+
status=models.TaskStatus.IN_PROGRESS,
|
|
125
|
+
last_started_at=time.time(),
|
|
126
|
+
)
|
|
127
|
+
]
|
|
128
|
+
)
|
|
129
|
+
persistence.save_tasks(tasks_file, data)
|
|
130
|
+
|
|
131
|
+
finished = lifecycle.finish_task_attempt(tasks_file, "1")
|
|
132
|
+
assert finished.status == models.TaskStatus.PENDING
|
|
133
|
+
assert finished.pid is None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_update_heartbeat(tmp_path):
|
|
137
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
138
|
+
data = models.Roadmap(
|
|
139
|
+
tasks=[
|
|
140
|
+
models.Task(
|
|
141
|
+
id="1",
|
|
142
|
+
description="Task 1",
|
|
143
|
+
status=models.TaskStatus.IN_PROGRESS,
|
|
144
|
+
)
|
|
145
|
+
]
|
|
146
|
+
)
|
|
147
|
+
persistence.save_tasks(tasks_file, data)
|
|
148
|
+
|
|
149
|
+
success = lifecycle.update_heartbeat(tasks_file, "1", pid=123)
|
|
150
|
+
assert success is True
|
|
151
|
+
|
|
152
|
+
updated_data = persistence.load_tasks(tasks_file)
|
|
153
|
+
assert updated_data.tasks[0].last_heartbeat is not None
|
|
154
|
+
assert updated_data.tasks[0].pid == 123
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_cancel_task(tmp_path):
|
|
158
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
159
|
+
data = models.Roadmap(
|
|
160
|
+
tasks=[
|
|
161
|
+
models.Task(
|
|
162
|
+
id="1",
|
|
163
|
+
description="Task 1",
|
|
164
|
+
status=models.TaskStatus.IN_PROGRESS,
|
|
165
|
+
pid=os.getpid(),
|
|
166
|
+
)
|
|
167
|
+
],
|
|
168
|
+
)
|
|
169
|
+
persistence.save_tasks(tasks_file, data)
|
|
170
|
+
|
|
171
|
+
with patch("lemming.tasks.lifecycle.os.killpg"):
|
|
172
|
+
success = lifecycle.cancel_task(tasks_file, "1")
|
|
173
|
+
assert success is True
|
|
174
|
+
|
|
175
|
+
updated_data = persistence.load_tasks(tasks_file)
|
|
176
|
+
assert updated_data.tasks[0].status == models.TaskStatus.CANCELLED
|
|
177
|
+
assert updated_data.tasks[0].pid is None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_cancel_task_kills_loop_pid(tmp_path):
|
|
181
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
182
|
+
data = models.Roadmap(
|
|
183
|
+
tasks=[
|
|
184
|
+
models.Task(
|
|
185
|
+
id="1",
|
|
186
|
+
description="Task 1",
|
|
187
|
+
status=models.TaskStatus.IN_PROGRESS,
|
|
188
|
+
pid=123,
|
|
189
|
+
)
|
|
190
|
+
],
|
|
191
|
+
)
|
|
192
|
+
persistence.save_tasks(tasks_file, data)
|
|
193
|
+
|
|
194
|
+
# Mock get_loop_pid to return a dummy PID
|
|
195
|
+
with (
|
|
196
|
+
patch("lemming.persistence.get_loop_pid", return_value=456),
|
|
197
|
+
patch("lemming.tasks.lifecycle.os.getpgid", return_value=123),
|
|
198
|
+
patch("lemming.tasks.lifecycle.os.kill") as mock_kill,
|
|
199
|
+
patch("lemming.tasks.lifecycle.os.killpg") as mock_killpg,
|
|
200
|
+
):
|
|
201
|
+
success = lifecycle.cancel_task(tasks_file, "1")
|
|
202
|
+
assert success is True
|
|
203
|
+
|
|
204
|
+
# Verify task PID was killed
|
|
205
|
+
mock_killpg.assert_called_once_with(123, signal.SIGTERM)
|
|
206
|
+
# Verify loop PID was killed
|
|
207
|
+
mock_kill.assert_any_call(456, signal.SIGTERM)
|
|
208
|
+
|
|
209
|
+
# Verify task is marked as cancelled
|
|
210
|
+
updated_data = persistence.load_tasks(tasks_file)
|
|
211
|
+
assert updated_data.tasks[0].status == models.TaskStatus.CANCELLED
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_reset_task(tmp_path):
|
|
215
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
216
|
+
task_id = "12345678"
|
|
217
|
+
data = models.Roadmap(
|
|
218
|
+
tasks=[
|
|
219
|
+
models.Task(
|
|
220
|
+
id=task_id,
|
|
221
|
+
description="Task 1",
|
|
222
|
+
status=models.TaskStatus.COMPLETED,
|
|
223
|
+
progress=["done"],
|
|
224
|
+
)
|
|
225
|
+
],
|
|
226
|
+
)
|
|
227
|
+
persistence.save_tasks(tasks_file, data)
|
|
228
|
+
|
|
229
|
+
# Create log file
|
|
230
|
+
log_file = paths.get_log_file(tasks_file, task_id)
|
|
231
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
232
|
+
log_file.write_text("log content")
|
|
233
|
+
|
|
234
|
+
reset_task = lifecycle.reset_task(tasks_file, task_id)
|
|
235
|
+
assert reset_task.status == models.TaskStatus.PENDING
|
|
236
|
+
assert reset_task.progress == []
|
|
237
|
+
assert not log_file.exists()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@patch("lemming.tasks.lifecycle.is_pid_alive")
|
|
241
|
+
def test_is_task_active(mock_is_pid_alive):
|
|
242
|
+
now = time.time()
|
|
243
|
+
|
|
244
|
+
# 1. Pending task is never active
|
|
245
|
+
task_pending = models.Task(
|
|
246
|
+
id="1", description="test", status=models.TaskStatus.PENDING
|
|
247
|
+
)
|
|
248
|
+
assert not lifecycle.is_task_active(task_pending, now)
|
|
249
|
+
|
|
250
|
+
# 2. IN_PROGRESS but no PID -> not active
|
|
251
|
+
task_no_pid = models.Task(
|
|
252
|
+
id="2",
|
|
253
|
+
description="test",
|
|
254
|
+
status=models.TaskStatus.IN_PROGRESS,
|
|
255
|
+
last_heartbeat=now,
|
|
256
|
+
)
|
|
257
|
+
assert not lifecycle.is_task_active(task_no_pid, now)
|
|
258
|
+
|
|
259
|
+
# 3. IN_PROGRESS, has PID, PID dead -> not active
|
|
260
|
+
mock_is_pid_alive.return_value = False
|
|
261
|
+
task_dead_pid = models.Task(
|
|
262
|
+
id="3",
|
|
263
|
+
description="test",
|
|
264
|
+
status=models.TaskStatus.IN_PROGRESS,
|
|
265
|
+
pid=123,
|
|
266
|
+
last_heartbeat=now,
|
|
267
|
+
)
|
|
268
|
+
assert not lifecycle.is_task_active(task_dead_pid, now)
|
|
269
|
+
|
|
270
|
+
# 4. IN_PROGRESS, has PID, PID alive, stale heartbeat -> not active
|
|
271
|
+
mock_is_pid_alive.return_value = True
|
|
272
|
+
stale_time = now - lifecycle.STALE_THRESHOLD - 10
|
|
273
|
+
task_stale = models.Task(
|
|
274
|
+
id="4",
|
|
275
|
+
description="test",
|
|
276
|
+
status=models.TaskStatus.IN_PROGRESS,
|
|
277
|
+
pid=123,
|
|
278
|
+
last_heartbeat=stale_time,
|
|
279
|
+
)
|
|
280
|
+
assert not lifecycle.is_task_active(task_stale, now)
|
|
281
|
+
|
|
282
|
+
# 5. IN_PROGRESS, has PID, PID alive, fresh heartbeat -> active!
|
|
283
|
+
task_active = models.Task(
|
|
284
|
+
id="5",
|
|
285
|
+
description="test",
|
|
286
|
+
status=models.TaskStatus.IN_PROGRESS,
|
|
287
|
+
pid=123,
|
|
288
|
+
last_heartbeat=now,
|
|
289
|
+
)
|
|
290
|
+
assert lifecycle.is_task_active(task_active, now)
|
|
291
|
+
|
|
292
|
+
# 6. Finalizing (requested_status), no PID -> not active (ready for hooks)
|
|
293
|
+
task_finalizing = models.Task(
|
|
294
|
+
id="6",
|
|
295
|
+
description="test",
|
|
296
|
+
status=models.TaskStatus.IN_PROGRESS,
|
|
297
|
+
requested_status=models.TaskStatus.COMPLETED,
|
|
298
|
+
last_heartbeat=now,
|
|
299
|
+
)
|
|
300
|
+
assert not lifecycle.is_task_active(task_finalizing, now)
|
|
301
|
+
|
|
302
|
+
# 7. Finalizing (requested_status), PID alive, fresh heartbeat -> active!
|
|
303
|
+
# (hooks running)
|
|
304
|
+
task_hooks_running = models.Task(
|
|
305
|
+
id="7",
|
|
306
|
+
description="test",
|
|
307
|
+
status=models.TaskStatus.IN_PROGRESS,
|
|
308
|
+
requested_status=models.TaskStatus.COMPLETED,
|
|
309
|
+
pid=123,
|
|
310
|
+
last_heartbeat=now,
|
|
311
|
+
)
|
|
312
|
+
assert lifecycle.is_task_active(task_hooks_running, now)
|