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.
Files changed (94) hide show
  1. lemming/__init__.py +1 -0
  2. lemming/api/__init__.py +5 -0
  3. lemming/api/auth.py +34 -0
  4. lemming/api/auth_test.py +40 -0
  5. lemming/api/config.py +85 -0
  6. lemming/api/config_test.py +84 -0
  7. lemming/api/conftest.py +204 -0
  8. lemming/api/context.py +39 -0
  9. lemming/api/context_test.py +62 -0
  10. lemming/api/directories.py +57 -0
  11. lemming/api/directories_test.py +94 -0
  12. lemming/api/files.py +116 -0
  13. lemming/api/files_test.py +163 -0
  14. lemming/api/hooks.py +15 -0
  15. lemming/api/hooks_test.py +33 -0
  16. lemming/api/logging.py +16 -0
  17. lemming/api/logging_test.py +59 -0
  18. lemming/api/loop.py +45 -0
  19. lemming/api/loop_test.py +58 -0
  20. lemming/api/main.py +53 -0
  21. lemming/api/main_test.py +30 -0
  22. lemming/api/tasks.py +170 -0
  23. lemming/api/tasks_test.py +426 -0
  24. lemming/api.py +5 -0
  25. lemming/api_test.py +7 -0
  26. lemming/cli/__init__.py +12 -0
  27. lemming/cli/config.py +67 -0
  28. lemming/cli/config_test.py +50 -0
  29. lemming/cli/context.py +40 -0
  30. lemming/cli/context_test.py +49 -0
  31. lemming/cli/hooks.py +147 -0
  32. lemming/cli/hooks_test.py +50 -0
  33. lemming/cli/main.py +42 -0
  34. lemming/cli/main_test.py +21 -0
  35. lemming/cli/operations.py +226 -0
  36. lemming/cli/operations_test.py +44 -0
  37. lemming/cli/progress.py +54 -0
  38. lemming/cli/progress_test.py +40 -0
  39. lemming/cli/readability_cli.py +28 -0
  40. lemming/cli/readability_cli_test.py +57 -0
  41. lemming/cli/tasks.py +529 -0
  42. lemming/cli/tasks_test.py +168 -0
  43. lemming/cli.py +5 -0
  44. lemming/cli_test.py +22 -0
  45. lemming/conftest.py +13 -0
  46. lemming/hooks.py +145 -0
  47. lemming/hooks_test.py +180 -0
  48. lemming/integration_test.py +299 -0
  49. lemming/main.py +6 -0
  50. lemming/main_test.py +44 -0
  51. lemming/models.py +88 -0
  52. lemming/models_test.py +91 -0
  53. lemming/orchestrator.py +407 -0
  54. lemming/orchestrator_test.py +468 -0
  55. lemming/paths.py +245 -0
  56. lemming/paths_test.py +186 -0
  57. lemming/persistence.py +179 -0
  58. lemming/persistence_test.py +150 -0
  59. lemming/prompts/hooks/readability.md +42 -0
  60. lemming/prompts/hooks/roadmap.md +61 -0
  61. lemming/prompts/hooks/testing.md +44 -0
  62. lemming/prompts/taskrunner.md +69 -0
  63. lemming/prompts.py +354 -0
  64. lemming/prompts_test.py +421 -0
  65. lemming/providers.py +190 -0
  66. lemming/providers_test.py +73 -0
  67. lemming/runner.py +327 -0
  68. lemming/runner_test.py +378 -0
  69. lemming/tasks/__init__.py +75 -0
  70. lemming/tasks/lifecycle.py +331 -0
  71. lemming/tasks/lifecycle_test.py +312 -0
  72. lemming/tasks/operations.py +258 -0
  73. lemming/tasks/operations_test.py +172 -0
  74. lemming/tasks/progress.py +29 -0
  75. lemming/tasks/progress_test.py +22 -0
  76. lemming/tasks/queries.py +128 -0
  77. lemming/tasks/queries_test.py +233 -0
  78. lemming/web/dashboard.spec.js +350 -0
  79. lemming/web/dashboard.test.js +998 -0
  80. lemming/web/favicon.js +40 -0
  81. lemming/web/favicon.spec.js +242 -0
  82. lemming/web/files.html +375 -0
  83. lemming/web/files.spec.js +97 -0
  84. lemming/web/index.html +983 -0
  85. lemming/web/index.js +753 -0
  86. lemming/web/logs.html +358 -0
  87. lemming/web/logs.test.js +195 -0
  88. lemming/web/mancha.js +58 -0
  89. lemming/web/screenshots.spec.js +328 -0
  90. lemming_cli-0.1.0.dist-info/METADATA +314 -0
  91. lemming_cli-0.1.0.dist-info/RECORD +94 -0
  92. lemming_cli-0.1.0.dist-info/WHEEL +4 -0
  93. lemming_cli-0.1.0.dist-info/entry_points.txt +2 -0
  94. 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)