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,258 @@
1
+ """Task CRUD operations: add, update, delete, and context updates."""
2
+
3
+ import os
4
+ import pathlib
5
+ import time
6
+
7
+ from .. import models, persistence
8
+ from . import lifecycle
9
+
10
+
11
+ def add_task(
12
+ tasks_file: pathlib.Path,
13
+ description: str,
14
+ runner: str | None = None,
15
+ index: int = -1,
16
+ parent: str | None = None,
17
+ parent_tasks_file: str | None = None,
18
+ ) -> models.Task:
19
+ """Adds a new task to the roadmap.
20
+
21
+ Args:
22
+ tasks_file: Path to the tasks YAML file.
23
+ description: Description of the task.
24
+ runner: Optional preferred runner for this task.
25
+ index: Position to insert the task at (default: append).
26
+ parent: Optional parent task ID.
27
+ parent_tasks_file: Optional parent tasks file path.
28
+
29
+ Returns:
30
+ The newly created Task.
31
+ """
32
+ with persistence.lock_tasks(tasks_file):
33
+ data = persistence.load_tasks(tasks_file)
34
+
35
+ task_id = lifecycle.generate_task_id()
36
+ existing_ids = {t.id for t in data.tasks}
37
+ while task_id in existing_ids:
38
+ task_id = lifecycle.generate_task_id()
39
+
40
+ # Detect if we are running inside an agent and set parent automatically
41
+ if not parent:
42
+ parent = os.environ.get("LEMMING_PARENT_TASK_ID")
43
+ if not parent_tasks_file:
44
+ parent_tasks_file = os.environ.get("LEMMING_PARENT_TASKS_FILE")
45
+
46
+ new_task = models.Task(
47
+ id=task_id,
48
+ description=description,
49
+ runner=runner,
50
+ parent=parent,
51
+ parent_tasks_file=parent_tasks_file,
52
+ )
53
+
54
+ if index == -1:
55
+ data.tasks.append(new_task)
56
+ else:
57
+ data.tasks.insert(index, new_task)
58
+
59
+ persistence.save_tasks(tasks_file, data)
60
+ return new_task
61
+
62
+
63
+ def delete_tasks(
64
+ tasks_file: pathlib.Path,
65
+ task_id: str | None = None,
66
+ all_tasks: bool = False,
67
+ completed_only: bool = False,
68
+ ) -> int:
69
+ """Deletes tasks from the roadmap.
70
+
71
+ Args:
72
+ tasks_file: Path to the tasks YAML file.
73
+ task_id: Optional ID of a specific task to delete.
74
+ all_tasks: If True, deletes all tasks and clears context.
75
+ completed_only: If True, deletes only completed tasks.
76
+
77
+ Returns:
78
+ The number of tasks deleted.
79
+ """
80
+ with persistence.lock_tasks(tasks_file):
81
+ data = persistence.load_tasks(tasks_file)
82
+ initial_count = len(data.tasks)
83
+
84
+ if all_tasks:
85
+ for t in data.tasks:
86
+ lifecycle.reset_task_logs(tasks_file, t.id)
87
+ data.tasks = []
88
+ data.context = ""
89
+ elif completed_only:
90
+ completed_tasks = [
91
+ t
92
+ for t in data.tasks
93
+ if t.status
94
+ in (
95
+ models.TaskStatus.COMPLETED,
96
+ models.TaskStatus.FAILED,
97
+ models.TaskStatus.CANCELLED,
98
+ )
99
+ ]
100
+ for t in completed_tasks:
101
+ lifecycle.reset_task_logs(tasks_file, t.id)
102
+ data.tasks = [
103
+ t
104
+ for t in data.tasks
105
+ if t.status
106
+ not in (
107
+ models.TaskStatus.COMPLETED,
108
+ models.TaskStatus.FAILED,
109
+ models.TaskStatus.CANCELLED,
110
+ )
111
+ ]
112
+ elif task_id:
113
+ tasks_to_delete = [
114
+ t for t in data.tasks if t.id.startswith(task_id)
115
+ ]
116
+ for t in tasks_to_delete:
117
+ lifecycle.reset_task_logs(tasks_file, t.id)
118
+ data.tasks = [t for t in data.tasks if not t.id.startswith(task_id)]
119
+
120
+ persistence.save_tasks(tasks_file, data)
121
+ return initial_count - len(data.tasks)
122
+
123
+
124
+ def update_task(
125
+ tasks_file: pathlib.Path,
126
+ task_id: str,
127
+ description: str | None = None,
128
+ runner: str | None = None,
129
+ index: int | None = None,
130
+ status: str | None = None,
131
+ require_progress: bool = False,
132
+ parent: str | None = None,
133
+ parent_tasks_file: str | None = None,
134
+ force: bool = False,
135
+ ) -> models.Task:
136
+ """Updates an existing task.
137
+
138
+ Args:
139
+ tasks_file: Path to the tasks YAML file.
140
+ task_id: ID of the task to update.
141
+ description: New description.
142
+ runner: New preferred runner.
143
+ index: New position in the task list.
144
+ status: New status.
145
+ require_progress: If True, raises ValueError if the task has no
146
+ progress.
147
+ parent: New parent task ID.
148
+ parent_tasks_file: New parent tasks file path.
149
+ force: If True, force status transition even if task is in progress.
150
+
151
+ Returns:
152
+ The updated Task.
153
+
154
+ Raises:
155
+ ValueError: If the task is not found, or if validation fails.
156
+ """
157
+ with persistence.lock_tasks(tasks_file):
158
+ data = persistence.load_tasks(tasks_file)
159
+
160
+ # Find the task
161
+ task_idx = -1
162
+ target = None
163
+ for i, t in enumerate(data.tasks):
164
+ if t.id.startswith(task_id):
165
+ task_idx = i
166
+ target = t
167
+ break
168
+
169
+ if not target:
170
+ raise models.TaskNotFoundError(f"Task {task_id} not found")
171
+
172
+ if target.status == models.TaskStatus.COMPLETED and description:
173
+ raise ValueError("Cannot edit description of a completed task")
174
+
175
+ if require_progress and not target.progress:
176
+ raise ValueError(
177
+ f"Task {target.id} has no recorded progress. "
178
+ "Record at least one progress entry before completing "
179
+ "or failing."
180
+ )
181
+
182
+ if description is not None:
183
+ target.description = description
184
+ if runner is not None:
185
+ target.runner = runner
186
+ if parent is not None:
187
+ if parent == "":
188
+ target.parent = None
189
+ else:
190
+ target.parent = parent
191
+ if parent_tasks_file is not None:
192
+ if parent_tasks_file == "":
193
+ target.parent_tasks_file = None
194
+ else:
195
+ target.parent_tasks_file = parent_tasks_file
196
+
197
+ if status and status != target.status:
198
+ # If the task is currently in progress, we don't transition to
199
+ # completed/failed immediately. We set requested_status so the
200
+ # orchestrator can run hooks before final completion.
201
+ if (
202
+ not force
203
+ and target.status == models.TaskStatus.IN_PROGRESS
204
+ and status
205
+ in (
206
+ models.TaskStatus.COMPLETED,
207
+ models.TaskStatus.FAILED,
208
+ models.TaskStatus.CANCELLED,
209
+ )
210
+ ):
211
+ lifecycle.update_run_time(target)
212
+ target.requested_status = models.TaskStatus(status)
213
+ target.last_started_at = (
214
+ time.time()
215
+ ) # Track hook execution time
216
+ else:
217
+ if target.status == models.TaskStatus.IN_PROGRESS:
218
+ lifecycle.update_run_time(target)
219
+
220
+ target.status = models.TaskStatus(status)
221
+ if status in (
222
+ models.TaskStatus.COMPLETED,
223
+ models.TaskStatus.FAILED,
224
+ models.TaskStatus.CANCELLED,
225
+ ):
226
+ target.completed_at = time.time()
227
+ target.pid = None
228
+ target.last_heartbeat = None
229
+ target.requested_status = None
230
+ elif status == models.TaskStatus.PENDING:
231
+ target.completed_at = None
232
+ target.attempts = 0
233
+ target.requested_status = None
234
+ elif target.completed_at is not None:
235
+ target.completed_at = None
236
+
237
+ if index is not None:
238
+ task_to_move = data.tasks.pop(task_idx)
239
+ if index == -1:
240
+ data.tasks.append(task_to_move)
241
+ else:
242
+ data.tasks.insert(index, task_to_move)
243
+
244
+ persistence.save_tasks(tasks_file, data)
245
+ return target
246
+
247
+
248
+ def update_context(tasks_file: pathlib.Path, context: str) -> None:
249
+ """Updates the project context.
250
+
251
+ Args:
252
+ tasks_file: Path to the tasks YAML file.
253
+ context: The new project context string.
254
+ """
255
+ with persistence.lock_tasks(tasks_file):
256
+ data = persistence.load_tasks(tasks_file)
257
+ data.context = context
258
+ persistence.save_tasks(tasks_file, data)
@@ -0,0 +1,172 @@
1
+ import os
2
+ import time
3
+ from unittest.mock import patch
4
+
5
+ import pytest
6
+
7
+ from .. import models, persistence
8
+ from . import operations
9
+
10
+
11
+ def test_add_task_captures_parent_project(tmp_path):
12
+ tasks_file = tmp_path / "tasks.yml"
13
+ parent_file = tmp_path / "parent_tasks.yml"
14
+
15
+ with patch.dict(
16
+ os.environ,
17
+ {
18
+ "LEMMING_PARENT_TASK_ID": "parent123",
19
+ "LEMMING_PARENT_TASKS_FILE": str(parent_file),
20
+ },
21
+ ):
22
+ task = operations.add_task(tasks_file, "child task")
23
+ assert task.parent == "parent123"
24
+ assert task.parent_tasks_file == str(parent_file)
25
+
26
+ # Manual override
27
+ task2 = operations.add_task(tasks_file, "another task", parent="override")
28
+ assert task2.parent == "override"
29
+ assert task2.parent_tasks_file is None
30
+
31
+
32
+ def test_add_task(tmp_path):
33
+ tasks_file = tmp_path / "tasks.yml"
34
+ # Scaffold tasks file
35
+ persistence.save_tasks(tasks_file, models.Roadmap())
36
+
37
+ now = time.time()
38
+ task = operations.add_task(tasks_file, "New task")
39
+ assert task.description == "New task"
40
+ assert task.status == models.TaskStatus.PENDING
41
+ assert task.created_at >= now
42
+
43
+ data = persistence.load_tasks(tasks_file)
44
+ assert len(data.tasks) == 1
45
+ assert data.tasks[0].created_at == task.created_at
46
+
47
+
48
+ def test_update_task_description(tmp_path):
49
+ tasks_file = tmp_path / "tasks.yml"
50
+ task = operations.add_task(tasks_file, "Old description")
51
+ task_id = task.id
52
+
53
+ # 1. Successful update
54
+ updated = operations.update_task(
55
+ tasks_file, task_id, description="New description"
56
+ )
57
+ assert updated.description == "New description"
58
+
59
+ # 2. Cannot edit description of a completed task
60
+ operations.update_task(
61
+ tasks_file, task_id, status=models.TaskStatus.COMPLETED
62
+ )
63
+
64
+ with pytest.raises(
65
+ ValueError, match="Cannot edit description of a completed task"
66
+ ):
67
+ operations.update_task(
68
+ tasks_file, task_id, description="Trying to change"
69
+ )
70
+
71
+
72
+ def test_update_task_runner(tmp_path):
73
+ tasks_file = tmp_path / "tasks.yml"
74
+ task = operations.add_task(tasks_file, "Task without runner")
75
+ task_id = task.id
76
+
77
+ assert task.runner is None
78
+
79
+ updated = operations.update_task(
80
+ tasks_file, task_id, runner="custom-runner"
81
+ )
82
+ assert updated.runner == "custom-runner"
83
+
84
+
85
+ def test_update_task_index(tmp_path):
86
+ tasks_file = tmp_path / "tasks.yml"
87
+ operations.add_task(tasks_file, "Task 0")
88
+ operations.add_task(tasks_file, "Task 1")
89
+ t2 = operations.add_task(tasks_file, "Task 2")
90
+ task_id = t2.id
91
+
92
+ # data.tasks is [Task 0, Task 1, Task 2]
93
+ # Move Task 2 to index 0
94
+ operations.update_task(tasks_file, task_id, index=0)
95
+ data = persistence.load_tasks(tasks_file)
96
+ assert [t.description for t in data.tasks] == ["Task 2", "Task 0", "Task 1"]
97
+
98
+ # Move Task 2 to the end
99
+ operations.update_task(tasks_file, task_id, index=-1)
100
+ data = persistence.load_tasks(tasks_file)
101
+ assert [t.description for t in data.tasks] == ["Task 0", "Task 1", "Task 2"]
102
+
103
+
104
+ def test_update_task_status_lifecycle(tmp_path):
105
+ tasks_file = tmp_path / "tasks.yml"
106
+ task = operations.add_task(tasks_file, "Status lifecycle")
107
+ task_id = task.id
108
+
109
+ # 1. PENDING -> COMPLETED
110
+ updated = operations.update_task(
111
+ tasks_file, task_id, status=models.TaskStatus.COMPLETED
112
+ )
113
+ assert updated.status == models.TaskStatus.COMPLETED
114
+ assert updated.completed_at is not None
115
+
116
+ # 2. COMPLETED -> PENDING (resets attempts and completed_at)
117
+ with persistence.lock_tasks(tasks_file):
118
+ data = persistence.load_tasks(tasks_file)
119
+ data.tasks[0].attempts = 5
120
+ persistence.save_tasks(tasks_file, data)
121
+
122
+ updated = operations.update_task(
123
+ tasks_file, task_id, status=models.TaskStatus.PENDING
124
+ )
125
+ assert updated.status == models.TaskStatus.PENDING
126
+ assert updated.completed_at is None
127
+ assert updated.attempts == 0
128
+
129
+
130
+ def test_update_task_parent_fields(tmp_path):
131
+ tasks_file = tmp_path / "tasks.yml"
132
+ task = operations.add_task(tasks_file, "Update parent fields")
133
+ task_id = task.id
134
+
135
+ # 1. Update with values
136
+ updated = operations.update_task(
137
+ tasks_file,
138
+ task_id,
139
+ parent="parent123",
140
+ parent_tasks_file="parent_tasks.yml",
141
+ )
142
+ assert updated.parent == "parent123"
143
+ assert updated.parent_tasks_file == "parent_tasks.yml"
144
+
145
+ # 2. Clear values with empty strings
146
+ cleared = operations.update_task(
147
+ tasks_file,
148
+ task_id,
149
+ parent="",
150
+ parent_tasks_file="",
151
+ )
152
+ assert cleared.parent is None
153
+ assert cleared.parent_tasks_file is None
154
+
155
+
156
+ def test_delete_tasks(tmp_path):
157
+ tasks_file = tmp_path / "tasks.yml"
158
+ operations.add_task(tasks_file, "Task 1")
159
+ t2 = operations.add_task(tasks_file, "Task 2")
160
+
161
+ count = operations.delete_tasks(tasks_file, task_id=t2.id)
162
+ assert count == 1
163
+ data = persistence.load_tasks(tasks_file)
164
+ assert len(data.tasks) == 1
165
+ assert data.tasks[0].description == "Task 1"
166
+
167
+
168
+ def test_update_context(tmp_path):
169
+ tasks_file = tmp_path / "tasks.yml"
170
+ operations.update_context(tasks_file, "new context")
171
+ data = persistence.load_tasks(tasks_file)
172
+ assert data.context == "new context"
@@ -0,0 +1,29 @@
1
+ """Recording of task progress entries."""
2
+
3
+ import pathlib
4
+
5
+ from .. import models, persistence
6
+
7
+
8
+ def add_progress(
9
+ tasks_file: pathlib.Path, task_id: str, text: str
10
+ ) -> models.Task:
11
+ """Adds a progress entry to a task.
12
+
13
+ Args:
14
+ tasks_file: Path to the tasks YAML file.
15
+ task_id: ID of the task to add progress to.
16
+ text: The progress text to add.
17
+
18
+ Returns:
19
+ The updated Task.
20
+ """
21
+ with persistence.lock_tasks(tasks_file):
22
+ data = persistence.load_tasks(tasks_file)
23
+ target = next((t for t in data.tasks if t.id.startswith(task_id)), None)
24
+ if not target:
25
+ raise ValueError(f"Task {task_id} not found")
26
+
27
+ target.progress.append(text)
28
+ persistence.save_tasks(tasks_file, data)
29
+ return target
@@ -0,0 +1,22 @@
1
+ from lemming import models, persistence
2
+ from lemming.tasks import progress
3
+
4
+
5
+ def test_add_progress(tmp_path):
6
+ tasks_file = tmp_path / "tasks.yml"
7
+ data = models.Roadmap(
8
+ tasks=[
9
+ models.Task(
10
+ id="123",
11
+ description="Task 1",
12
+ )
13
+ ]
14
+ )
15
+ # create file to test load/save logic
16
+ persistence.save_tasks(tasks_file, data)
17
+
18
+ target = progress.add_progress(tasks_file, "123", "Found bug in module X")
19
+
20
+ assert target.id == "123"
21
+ assert len(target.progress) == 1
22
+ assert target.progress[0] == "Found bug in module X"
@@ -0,0 +1,128 @@
1
+ """Read-only queries over project tasks and orchestrator state."""
2
+
3
+ import pathlib
4
+ import time
5
+
6
+ from .. import models, paths, persistence
7
+ from . import lifecycle
8
+
9
+
10
+ def get_project_data(tasks_file: pathlib.Path) -> models.ProjectData:
11
+ """Consolidated logic to get project state (tasks, context, loop status).
12
+
13
+ Args:
14
+ tasks_file: Path to the tasks YAML file.
15
+
16
+ Returns:
17
+ A ProjectData containing the enriched project state.
18
+ """
19
+ with persistence.read_lock_tasks(tasks_file):
20
+ data = persistence.load_tasks(tasks_file)
21
+ now = time.time()
22
+
23
+ # Deduplicate tasks by ID and enrich metadata.
24
+ seen_ids = set()
25
+ unique_tasks = []
26
+ loop_running = lifecycle.is_loop_running(tasks_file)
27
+
28
+ for i, t in enumerate(data.tasks):
29
+ if t.id in seen_ids:
30
+ continue
31
+ seen_ids.add(t.id)
32
+ unique_tasks.append(t)
33
+
34
+ t.index = i
35
+ t.has_runner_log = paths.get_log_file(tasks_file, t.id).exists()
36
+
37
+ # Detect active loop from task heartbeats if not already known.
38
+ if not loop_running and lifecycle.is_task_active(t, now):
39
+ loop_running = True
40
+
41
+ # Unified sort: uncompleted (pending, in_progress) first, then completed
42
+ # (completed, failed). Within each group:
43
+ # - Uncompleted: in_progress tasks first, then by index (YAML order),
44
+ # then by created_at.
45
+ # - Completed: newer tasks appear first (reverse chronological).
46
+ def sort_key(t):
47
+ is_done = t.status in (
48
+ models.TaskStatus.COMPLETED,
49
+ models.TaskStatus.FAILED,
50
+ models.TaskStatus.CANCELLED,
51
+ )
52
+ is_in_progress = t.status == models.TaskStatus.IN_PROGRESS
53
+ if not is_done:
54
+ # For pending/in_progress, prioritize in_progress and then YAML
55
+ # order (index)
56
+ return (
57
+ 0,
58
+ 0 if is_in_progress else 1,
59
+ (t.index if t.index is not None else 0),
60
+ (t.created_at or 0),
61
+ )
62
+ else:
63
+ # For completed/failed, we want newest first
64
+ return (
65
+ 1,
66
+ -(t.completed_at or t.created_at or 0),
67
+ -(t.index if t.index is not None else 0),
68
+ )
69
+
70
+ unique_tasks.sort(key=sort_key)
71
+
72
+ return models.ProjectData(
73
+ context=data.context,
74
+ tasks=unique_tasks,
75
+ config=data.config,
76
+ cwd=str(paths.get_working_dir(tasks_file)),
77
+ loop_running=loop_running,
78
+ )
79
+
80
+
81
+ def get_pending_task(data: models.Roadmap) -> models.Task | None:
82
+ """Finds the next task to be executed.
83
+
84
+ Args:
85
+ data: The Roadmap to search.
86
+
87
+ Returns:
88
+ The next Task to execute, or None if no tasks are pending or a task
89
+ is already in progress.
90
+ """
91
+ now = time.time()
92
+
93
+ # 1. Check if ANY task is currently active (running or running hooks).
94
+ # If so, we must not start any other task (unless it's finalizing).
95
+ for task in data.tasks:
96
+ if lifecycle.is_task_active(task, now):
97
+ return None
98
+
99
+ # If it's NOT active, but it has a requested_status, it means it's a
100
+ # crashed finalizing task. We MUST pick it up immediately.
101
+ if task.requested_status:
102
+ return task
103
+
104
+ # 2. Sort uncompleted tasks (pending or stale in_progress) to pick the
105
+ # oldest one (FIFO). Note: we exclude tasks that are currently finalizing
106
+ # (have requested_status).
107
+ uncompleted = []
108
+ for i, task in enumerate(data.tasks):
109
+ if (
110
+ task.status
111
+ in (models.TaskStatus.PENDING, models.TaskStatus.IN_PROGRESS)
112
+ and not task.requested_status
113
+ ):
114
+ # We need to preserve the original index for tie-breaking
115
+ task.index = i
116
+ uncompleted.append(task)
117
+
118
+ if not uncompleted:
119
+ return None
120
+
121
+ def sort_key(t):
122
+ return (
123
+ (t.index if t.index is not None else 0),
124
+ (t.created_at or 0),
125
+ )
126
+
127
+ uncompleted.sort(key=sort_key)
128
+ return uncompleted[0]