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,468 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from unittest import mock
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from lemming import tasks
|
|
7
|
+
from lemming.orchestrator import (
|
|
8
|
+
_handle_runner_exit,
|
|
9
|
+
_process_exhausted_retries,
|
|
10
|
+
_process_finalizing_task,
|
|
11
|
+
format_duration,
|
|
12
|
+
parse_timeout,
|
|
13
|
+
run_loop,
|
|
14
|
+
)
|
|
15
|
+
from lemming.runner import RETURNCODE_TIMEOUT
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def setup_env(tmp_path):
|
|
20
|
+
test_tasks_file = tmp_path / "tasks_test.yml"
|
|
21
|
+
|
|
22
|
+
# Scaffold a valid file with one task
|
|
23
|
+
initial_data = tasks.Roadmap(
|
|
24
|
+
context="Initial context",
|
|
25
|
+
tasks=[
|
|
26
|
+
tasks.Task(
|
|
27
|
+
id="task1",
|
|
28
|
+
description="Task 1",
|
|
29
|
+
status=tasks.TaskStatus.PENDING,
|
|
30
|
+
attempts=0,
|
|
31
|
+
progress=[],
|
|
32
|
+
)
|
|
33
|
+
],
|
|
34
|
+
config=tasks.RoadmapConfig(retries=3, runner="agy"),
|
|
35
|
+
)
|
|
36
|
+
tasks.save_tasks(test_tasks_file, initial_data)
|
|
37
|
+
return test_tasks_file, initial_data
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_parse_timeout():
|
|
41
|
+
assert parse_timeout("0") == 0.0
|
|
42
|
+
assert parse_timeout("-1h") == 0.0
|
|
43
|
+
assert parse_timeout("8h") == 8 * 3600.0
|
|
44
|
+
assert parse_timeout("30m") == 30 * 60.0
|
|
45
|
+
assert parse_timeout("90s") == 90.0
|
|
46
|
+
assert parse_timeout("invalid") == 0.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_format_duration():
|
|
50
|
+
assert format_duration(0) == "none"
|
|
51
|
+
assert format_duration(-1) == "none"
|
|
52
|
+
assert format_duration(30) == "30m"
|
|
53
|
+
assert format_duration(60) == "1h"
|
|
54
|
+
assert format_duration(90) == "90m"
|
|
55
|
+
assert format_duration(120) == "2h"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@mock.patch("subprocess.Popen")
|
|
59
|
+
def test_run_loop_success(mock_popen, setup_env):
|
|
60
|
+
test_tasks_file, initial_data = setup_env
|
|
61
|
+
# Simulate runner reporting success
|
|
62
|
+
mock_process = mock.MagicMock()
|
|
63
|
+
mock_process.pid = 12345
|
|
64
|
+
mock_process.poll.side_effect = [None, 0]
|
|
65
|
+
mock_process.returncode = 0
|
|
66
|
+
mock_process.stdout = iter(["stdout\n"])
|
|
67
|
+
mock_process.communicate.return_value = ("stdout", "stderr")
|
|
68
|
+
|
|
69
|
+
def wait_side_effect():
|
|
70
|
+
with tasks.lock_tasks(test_tasks_file):
|
|
71
|
+
data = tasks.load_tasks(test_tasks_file)
|
|
72
|
+
data.tasks[0].status = tasks.TaskStatus.COMPLETED
|
|
73
|
+
data.tasks[0].completed_at = time.time()
|
|
74
|
+
tasks.save_tasks(test_tasks_file, data)
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
mock_process.wait.side_effect = wait_side_effect
|
|
78
|
+
mock_popen.return_value = mock_process
|
|
79
|
+
|
|
80
|
+
run_loop(
|
|
81
|
+
test_tasks_file,
|
|
82
|
+
verbose=True,
|
|
83
|
+
retry_delay=0,
|
|
84
|
+
yolo=True,
|
|
85
|
+
no_defaults=False,
|
|
86
|
+
runner_args=(),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
data = tasks.load_tasks(test_tasks_file)
|
|
90
|
+
assert data.tasks[0].status == tasks.TaskStatus.COMPLETED
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@mock.patch("subprocess.Popen")
|
|
94
|
+
@mock.patch("time.sleep", return_value=None)
|
|
95
|
+
def test_run_loop_retry_and_fail(mock_sleep, mock_popen, setup_env):
|
|
96
|
+
test_tasks_file, initial_data = setup_env
|
|
97
|
+
# Runner finishes but doesn't report completion
|
|
98
|
+
mock_process = mock.MagicMock()
|
|
99
|
+
mock_process.pid = 12345
|
|
100
|
+
mock_process.poll.return_value = 0
|
|
101
|
+
mock_process.returncode = 0
|
|
102
|
+
mock_process.stdout = iter(["stdout\n"])
|
|
103
|
+
mock_process.communicate.return_value = ("stdout", "stderr")
|
|
104
|
+
mock_popen.return_value = mock_process
|
|
105
|
+
|
|
106
|
+
# Configure 2 retries
|
|
107
|
+
data = tasks.load_tasks(test_tasks_file)
|
|
108
|
+
data.config.retries = 2
|
|
109
|
+
tasks.save_tasks(test_tasks_file, data)
|
|
110
|
+
|
|
111
|
+
run_loop(
|
|
112
|
+
test_tasks_file,
|
|
113
|
+
verbose=True,
|
|
114
|
+
retry_delay=0,
|
|
115
|
+
yolo=True,
|
|
116
|
+
no_defaults=False,
|
|
117
|
+
runner_args=(),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
data = tasks.load_tasks(test_tasks_file)
|
|
121
|
+
assert data.tasks[0].attempts == 2
|
|
122
|
+
assert data.tasks[0].status == tasks.TaskStatus.FAILED
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_synchronous_hooks_execution_timing(setup_env):
|
|
126
|
+
"""Verifies a new task starts only AFTER previous hooks finished."""
|
|
127
|
+
test_tasks_file, initial_data = setup_env
|
|
128
|
+
# 1. Setup two pending tasks.
|
|
129
|
+
now = time.time()
|
|
130
|
+
with tasks.lock_tasks(test_tasks_file):
|
|
131
|
+
data = tasks.load_tasks(test_tasks_file)
|
|
132
|
+
data.tasks = [
|
|
133
|
+
tasks.Task(
|
|
134
|
+
id="task2",
|
|
135
|
+
description="Task 2",
|
|
136
|
+
status=tasks.TaskStatus.PENDING,
|
|
137
|
+
created_at=now,
|
|
138
|
+
),
|
|
139
|
+
tasks.Task(
|
|
140
|
+
id="task1",
|
|
141
|
+
description="Task 1",
|
|
142
|
+
status=tasks.TaskStatus.PENDING,
|
|
143
|
+
created_at=now + 10,
|
|
144
|
+
),
|
|
145
|
+
]
|
|
146
|
+
data.config.retries = 1
|
|
147
|
+
data.config.runner = "true"
|
|
148
|
+
tasks.save_tasks(test_tasks_file, data)
|
|
149
|
+
|
|
150
|
+
task_starts = {}
|
|
151
|
+
hook_ends = {}
|
|
152
|
+
|
|
153
|
+
def mocked_run_with_heartbeat(
|
|
154
|
+
cmd, t_file, t_id, verbose, echo_fn, header=None, cwd=None, time_limit=0
|
|
155
|
+
):
|
|
156
|
+
if header and header.startswith("Hook:"):
|
|
157
|
+
time.sleep(0.1)
|
|
158
|
+
hook_ends[t_id] = time.time()
|
|
159
|
+
return 0, "hook stdout", ""
|
|
160
|
+
else:
|
|
161
|
+
task_starts[t_id] = time.time()
|
|
162
|
+
return 0, "task stdout", ""
|
|
163
|
+
|
|
164
|
+
def mocked_finish_task_attempt(t_file, t_id):
|
|
165
|
+
with tasks.lock_tasks(t_file):
|
|
166
|
+
data = tasks.load_tasks(t_file)
|
|
167
|
+
task = next(t for t in data.tasks if t.id == t_id)
|
|
168
|
+
task.requested_status = tasks.TaskStatus.COMPLETED
|
|
169
|
+
task.status = tasks.TaskStatus.IN_PROGRESS
|
|
170
|
+
tasks.save_tasks(t_file, data)
|
|
171
|
+
return task
|
|
172
|
+
|
|
173
|
+
with (
|
|
174
|
+
mock.patch(
|
|
175
|
+
"lemming.runner.run_with_heartbeat",
|
|
176
|
+
side_effect=mocked_run_with_heartbeat,
|
|
177
|
+
),
|
|
178
|
+
mock.patch(
|
|
179
|
+
"lemming.tasks.finish_task_attempt",
|
|
180
|
+
side_effect=mocked_finish_task_attempt,
|
|
181
|
+
),
|
|
182
|
+
mock.patch("lemming.prompts.list_hooks", return_value=["test_hook"]),
|
|
183
|
+
mock.patch(
|
|
184
|
+
"lemming.prompts.prepare_hook_prompt", return_value="Dummy hook"
|
|
185
|
+
),
|
|
186
|
+
):
|
|
187
|
+
run_loop(
|
|
188
|
+
test_tasks_file,
|
|
189
|
+
verbose=True,
|
|
190
|
+
retry_delay=0,
|
|
191
|
+
yolo=True,
|
|
192
|
+
no_defaults=False,
|
|
193
|
+
runner_args=(),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
assert "task1" in task_starts
|
|
197
|
+
assert "task2" in task_starts
|
|
198
|
+
assert "task2" in hook_ends
|
|
199
|
+
|
|
200
|
+
assert task_starts["task1"] >= hook_ends["task2"], (
|
|
201
|
+
"Task 1 should start after Task 2 hooks finish"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@mock.patch("lemming.runner.run_with_heartbeat")
|
|
206
|
+
def test_run_loop_calls_runner_with_header(mock_run, setup_env):
|
|
207
|
+
test_tasks_file, initial_data = setup_env
|
|
208
|
+
# Setup runner mock
|
|
209
|
+
mock_run.return_value = (0, "output", "")
|
|
210
|
+
|
|
211
|
+
# Mock finish_task_attempt to return a completed task to end loop
|
|
212
|
+
mock_task = initial_data.tasks[0]
|
|
213
|
+
mock_task.status = tasks.TaskStatus.COMPLETED
|
|
214
|
+
with mock.patch(
|
|
215
|
+
"lemming.tasks.finish_task_attempt", return_value=mock_task
|
|
216
|
+
):
|
|
217
|
+
run_loop(
|
|
218
|
+
test_tasks_file,
|
|
219
|
+
verbose=False,
|
|
220
|
+
retry_delay=0,
|
|
221
|
+
yolo=True,
|
|
222
|
+
no_defaults=False,
|
|
223
|
+
runner_args=(),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# Verify run_with_heartbeat was called with header="Task Runner"
|
|
227
|
+
args, kwargs = mock_run.call_args
|
|
228
|
+
assert kwargs.get("header") == "Task Runner"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@mock.patch("lemming.runner.run_with_heartbeat")
|
|
232
|
+
@mock.patch("time.sleep", return_value=None)
|
|
233
|
+
def test_run_loop_cancelled(mock_sleep, mock_run, setup_env):
|
|
234
|
+
test_tasks_file, initial_data = setup_env
|
|
235
|
+
# Simulate a task being cancelled (exit code -15)
|
|
236
|
+
mock_run.return_value = (-15, "cancelled", "error")
|
|
237
|
+
|
|
238
|
+
# Configure retries to ensure it DOES NOT retry
|
|
239
|
+
data = tasks.load_tasks(test_tasks_file)
|
|
240
|
+
data.config.retries = 3
|
|
241
|
+
tasks.save_tasks(test_tasks_file, data)
|
|
242
|
+
|
|
243
|
+
run_loop(
|
|
244
|
+
test_tasks_file,
|
|
245
|
+
verbose=True,
|
|
246
|
+
retry_delay=1,
|
|
247
|
+
yolo=True,
|
|
248
|
+
no_defaults=False,
|
|
249
|
+
runner_args=(),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# It should only have 1 attempt
|
|
253
|
+
data = tasks.load_tasks(test_tasks_file)
|
|
254
|
+
assert data.tasks[0].attempts == 1
|
|
255
|
+
|
|
256
|
+
# Verify that sleep was NOT called with the retry_delay (1)
|
|
257
|
+
# It might be called with other values if I didn't mock enough,
|
|
258
|
+
# but here it should not be called at all after the break.
|
|
259
|
+
for call in mock_sleep.call_args_list:
|
|
260
|
+
assert call[0][0] != 1, (
|
|
261
|
+
"Should not sleep with retry_delay on cancellation"
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@mock.patch("lemming.runner.run_with_heartbeat")
|
|
266
|
+
@mock.patch("time.sleep", return_value=None)
|
|
267
|
+
def test_run_loop_timeout_retries(mock_sleep, mock_run, setup_env):
|
|
268
|
+
"""Verifies that a timed-out task is retried without running hooks."""
|
|
269
|
+
test_tasks_file, initial_data = setup_env
|
|
270
|
+
# First call: timeout. Second call: timeout again. Exhausts retries.
|
|
271
|
+
mock_run.return_value = (RETURNCODE_TIMEOUT, "output", "")
|
|
272
|
+
|
|
273
|
+
data = tasks.load_tasks(test_tasks_file)
|
|
274
|
+
data.config.retries = 2
|
|
275
|
+
data.config.time_limit = 60
|
|
276
|
+
tasks.save_tasks(test_tasks_file, data)
|
|
277
|
+
|
|
278
|
+
run_loop(
|
|
279
|
+
test_tasks_file,
|
|
280
|
+
verbose=False,
|
|
281
|
+
retry_delay=0,
|
|
282
|
+
yolo=True,
|
|
283
|
+
no_defaults=False,
|
|
284
|
+
runner_args=(),
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
data = tasks.load_tasks(test_tasks_file)
|
|
288
|
+
assert data.tasks[0].attempts == 2
|
|
289
|
+
assert data.tasks[0].status == tasks.TaskStatus.FAILED
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@mock.patch("lemming.runner.run_with_heartbeat")
|
|
293
|
+
@mock.patch("time.sleep", return_value=None)
|
|
294
|
+
def test_run_loop_passes_time_limit(mock_sleep, mock_run, setup_env):
|
|
295
|
+
"""Verifies that time_limit from config is passed to the runner."""
|
|
296
|
+
test_tasks_file, initial_data = setup_env
|
|
297
|
+
mock_run.return_value = (0, "output", "")
|
|
298
|
+
|
|
299
|
+
data = tasks.load_tasks(test_tasks_file)
|
|
300
|
+
data.config.time_limit = 30
|
|
301
|
+
tasks.save_tasks(test_tasks_file, data)
|
|
302
|
+
|
|
303
|
+
mock_task = initial_data.tasks[0]
|
|
304
|
+
mock_task.status = tasks.TaskStatus.COMPLETED
|
|
305
|
+
with mock.patch(
|
|
306
|
+
"lemming.tasks.finish_task_attempt", return_value=mock_task
|
|
307
|
+
):
|
|
308
|
+
run_loop(
|
|
309
|
+
test_tasks_file,
|
|
310
|
+
verbose=False,
|
|
311
|
+
retry_delay=0,
|
|
312
|
+
yolo=True,
|
|
313
|
+
no_defaults=False,
|
|
314
|
+
runner_args=(),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
_, kwargs = mock_run.call_args
|
|
318
|
+
assert kwargs.get("time_limit") == 30
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@mock.patch("lemming.orchestrator.run_hooks")
|
|
322
|
+
def test_process_exhausted_retries_aborts(mock_run_hooks, setup_env):
|
|
323
|
+
test_tasks_file, initial_data = setup_env
|
|
324
|
+
# 1. Setup task that has exhausted retries and won't be healed
|
|
325
|
+
data = tasks.load_tasks(test_tasks_file)
|
|
326
|
+
task = data.tasks[0]
|
|
327
|
+
task.attempts = 3
|
|
328
|
+
tasks.save_tasks(test_tasks_file, data)
|
|
329
|
+
|
|
330
|
+
should_abort = _process_exhausted_retries(
|
|
331
|
+
test_tasks_file,
|
|
332
|
+
task.id,
|
|
333
|
+
retries=3,
|
|
334
|
+
runner_name="agy",
|
|
335
|
+
yolo=True,
|
|
336
|
+
runner_args=(),
|
|
337
|
+
no_defaults=False,
|
|
338
|
+
verbose=False,
|
|
339
|
+
active_hooks=["roadmap"],
|
|
340
|
+
working_dir=None,
|
|
341
|
+
time_limit=1,
|
|
342
|
+
)
|
|
343
|
+
assert should_abort
|
|
344
|
+
mock_run_hooks.assert_called_once()
|
|
345
|
+
assert (
|
|
346
|
+
mock_run_hooks.call_args[1]["final_status"] == tasks.TaskStatus.FAILED
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@mock.patch("lemming.orchestrator.run_hooks")
|
|
351
|
+
def test_process_exhausted_retries_healed(mock_run_hooks, setup_env):
|
|
352
|
+
test_tasks_file, initial_data = setup_env
|
|
353
|
+
|
|
354
|
+
# Setup task but simulate a hook healing it by resetting attempts
|
|
355
|
+
def fake_run_hooks(*args, **kwargs):
|
|
356
|
+
data = tasks.load_tasks(test_tasks_file)
|
|
357
|
+
data.tasks[0].attempts = 0 # Healed!
|
|
358
|
+
tasks.save_tasks(test_tasks_file, data)
|
|
359
|
+
|
|
360
|
+
mock_run_hooks.side_effect = fake_run_hooks
|
|
361
|
+
|
|
362
|
+
data = tasks.load_tasks(test_tasks_file)
|
|
363
|
+
task = data.tasks[0]
|
|
364
|
+
task.attempts = 3
|
|
365
|
+
tasks.save_tasks(test_tasks_file, data)
|
|
366
|
+
|
|
367
|
+
should_abort = _process_exhausted_retries(
|
|
368
|
+
test_tasks_file,
|
|
369
|
+
task.id,
|
|
370
|
+
retries=3,
|
|
371
|
+
runner_name="agy",
|
|
372
|
+
yolo=True,
|
|
373
|
+
runner_args=(),
|
|
374
|
+
no_defaults=False,
|
|
375
|
+
verbose=False,
|
|
376
|
+
active_hooks=["roadmap"],
|
|
377
|
+
working_dir=None,
|
|
378
|
+
time_limit=1,
|
|
379
|
+
)
|
|
380
|
+
assert not should_abort
|
|
381
|
+
mock_run_hooks.assert_called_once()
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@mock.patch("lemming.orchestrator.run_hooks")
|
|
385
|
+
def test_process_finalizing_task(mock_run_hooks, setup_env):
|
|
386
|
+
test_tasks_file, initial_data = setup_env
|
|
387
|
+
_process_finalizing_task(
|
|
388
|
+
test_tasks_file,
|
|
389
|
+
"task1",
|
|
390
|
+
requested_status=tasks.TaskStatus.COMPLETED,
|
|
391
|
+
runner_name="agy",
|
|
392
|
+
yolo=True,
|
|
393
|
+
runner_args=(),
|
|
394
|
+
no_defaults=False,
|
|
395
|
+
verbose=False,
|
|
396
|
+
active_hooks=["readability"],
|
|
397
|
+
working_dir=None,
|
|
398
|
+
time_limit=1,
|
|
399
|
+
)
|
|
400
|
+
mock_run_hooks.assert_called_once()
|
|
401
|
+
assert (
|
|
402
|
+
mock_run_hooks.call_args[1]["final_status"]
|
|
403
|
+
== tasks.TaskStatus.COMPLETED
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
@mock.patch("lemming.orchestrator.run_hooks")
|
|
408
|
+
@mock.patch("time.sleep", return_value=None)
|
|
409
|
+
def test_handle_runner_exit_completes(mock_sleep, mock_run_hooks, setup_env):
|
|
410
|
+
test_tasks_file, initial_data = setup_env
|
|
411
|
+
# A task requesting completion runs hooks and doesn't abort loop
|
|
412
|
+
data = tasks.load_tasks(test_tasks_file)
|
|
413
|
+
task = data.tasks[0]
|
|
414
|
+
task.status = tasks.TaskStatus.IN_PROGRESS
|
|
415
|
+
tasks.save_tasks(test_tasks_file, data)
|
|
416
|
+
|
|
417
|
+
# Simulate agent calling `lemming complete` (finish_task_attempt
|
|
418
|
+
# checks requested_status)
|
|
419
|
+
tasks.update_task(
|
|
420
|
+
test_tasks_file, task.id, status=tasks.TaskStatus.COMPLETED
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
should_abort = _handle_runner_exit(
|
|
424
|
+
test_tasks_file,
|
|
425
|
+
task.id,
|
|
426
|
+
returncode=0,
|
|
427
|
+
stdout="done",
|
|
428
|
+
stderr="",
|
|
429
|
+
retries=3,
|
|
430
|
+
retry_delay=5,
|
|
431
|
+
runner_name="agy",
|
|
432
|
+
yolo=True,
|
|
433
|
+
runner_args=(),
|
|
434
|
+
no_defaults=False,
|
|
435
|
+
verbose=False,
|
|
436
|
+
active_hooks=[],
|
|
437
|
+
working_dir=None,
|
|
438
|
+
time_limit=1,
|
|
439
|
+
)
|
|
440
|
+
assert not should_abort
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
@mock.patch("lemming.orchestrator.run_hooks")
|
|
444
|
+
def test_handle_runner_exit_cancelled(mock_run_hooks, setup_env):
|
|
445
|
+
test_tasks_file, initial_data = setup_env
|
|
446
|
+
data = tasks.load_tasks(test_tasks_file)
|
|
447
|
+
task = data.tasks[0]
|
|
448
|
+
task.status = tasks.TaskStatus.IN_PROGRESS
|
|
449
|
+
tasks.save_tasks(test_tasks_file, data)
|
|
450
|
+
|
|
451
|
+
should_abort = _handle_runner_exit(
|
|
452
|
+
test_tasks_file,
|
|
453
|
+
task.id,
|
|
454
|
+
returncode=-15,
|
|
455
|
+
stdout="",
|
|
456
|
+
stderr="",
|
|
457
|
+
retries=3,
|
|
458
|
+
retry_delay=5,
|
|
459
|
+
runner_name="agy",
|
|
460
|
+
yolo=True,
|
|
461
|
+
runner_args=(),
|
|
462
|
+
no_defaults=False,
|
|
463
|
+
verbose=False,
|
|
464
|
+
active_hooks=[],
|
|
465
|
+
working_dir=None,
|
|
466
|
+
time_limit=1,
|
|
467
|
+
)
|
|
468
|
+
assert should_abort, "Cancellation (-15) should abort the loop"
|
lemming/paths.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Path resolution, .env loading, and git helpers for Lemming."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import hashlib
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import pathlib
|
|
8
|
+
import stat
|
|
9
|
+
import subprocess
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _parse_dotenv(path: pathlib.Path) -> dict[str, str]:
|
|
15
|
+
"""Parse a .env file into a dict, skipping comments and blank lines."""
|
|
16
|
+
result: dict[str, str] = {}
|
|
17
|
+
try:
|
|
18
|
+
for lineno, raw_line in enumerate(path.read_text().splitlines(), 1):
|
|
19
|
+
line = raw_line.strip()
|
|
20
|
+
if not line or line.startswith("#"):
|
|
21
|
+
continue
|
|
22
|
+
# Optional "export " prefix
|
|
23
|
+
if line.startswith("export "):
|
|
24
|
+
line = line[7:].strip()
|
|
25
|
+
if "=" not in line:
|
|
26
|
+
logger.warning(
|
|
27
|
+
"%s:%d: skipping malformed line (no '=')", path, lineno
|
|
28
|
+
)
|
|
29
|
+
continue
|
|
30
|
+
key, value = line.split("=", 1)
|
|
31
|
+
key = key.strip()
|
|
32
|
+
# Strip surrounding quotes from value
|
|
33
|
+
value = value.strip()
|
|
34
|
+
if (
|
|
35
|
+
len(value) >= 2
|
|
36
|
+
and value[0] == value[-1]
|
|
37
|
+
and value[0] in ('"', "'")
|
|
38
|
+
):
|
|
39
|
+
value = value[1:-1]
|
|
40
|
+
result[key] = value
|
|
41
|
+
except FileNotFoundError:
|
|
42
|
+
pass
|
|
43
|
+
except Exception as exc:
|
|
44
|
+
logger.warning("Failed to read %s: %s", path, exc)
|
|
45
|
+
return result
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _check_permissions(path: pathlib.Path) -> None:
|
|
49
|
+
"""Warn if the .env file is readable by group or others."""
|
|
50
|
+
try:
|
|
51
|
+
mode = path.stat().st_mode
|
|
52
|
+
if mode & (stat.S_IRGRP | stat.S_IROTH):
|
|
53
|
+
logger.warning(
|
|
54
|
+
"%s has overly permissive permissions (%o). "
|
|
55
|
+
"Consider restricting to 600.",
|
|
56
|
+
path,
|
|
57
|
+
stat.S_IMODE(mode),
|
|
58
|
+
)
|
|
59
|
+
except OSError:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_dotenv(project_dir: pathlib.Path | None = None) -> None:
|
|
64
|
+
"""Load environment variables from .env files.
|
|
65
|
+
|
|
66
|
+
Precedence (later wins, but real env vars always take priority):
|
|
67
|
+
1. ~/.local/lemming/.env (global defaults)
|
|
68
|
+
2. <project_dir>/.env (project overrides)
|
|
69
|
+
|
|
70
|
+
Existing environment variables are never overwritten.
|
|
71
|
+
"""
|
|
72
|
+
env_files: list[pathlib.Path] = []
|
|
73
|
+
|
|
74
|
+
global_env = get_lemming_home() / ".env"
|
|
75
|
+
if global_env.is_file():
|
|
76
|
+
env_files.append(global_env)
|
|
77
|
+
|
|
78
|
+
if project_dir is not None:
|
|
79
|
+
project_env = project_dir / ".env"
|
|
80
|
+
if project_env.is_file() and project_env != global_env:
|
|
81
|
+
env_files.append(project_env)
|
|
82
|
+
|
|
83
|
+
merged: dict[str, str] = {}
|
|
84
|
+
for env_file in env_files:
|
|
85
|
+
_check_permissions(env_file)
|
|
86
|
+
merged.update(_parse_dotenv(env_file))
|
|
87
|
+
|
|
88
|
+
# Only set vars that aren't already in the environment
|
|
89
|
+
for key, value in merged.items():
|
|
90
|
+
if key not in os.environ:
|
|
91
|
+
os.environ[key] = value
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_lemming_home() -> pathlib.Path:
|
|
95
|
+
"""Determines the Lemming home directory from the environment or default.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
A pathlib.Path representing the Lemming home directory.
|
|
99
|
+
"""
|
|
100
|
+
home_override = os.environ.get("LEMMING_HOME")
|
|
101
|
+
if home_override:
|
|
102
|
+
return pathlib.Path(home_override)
|
|
103
|
+
return pathlib.Path.home() / ".local" / "lemming"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def get_global_hooks_dir() -> pathlib.Path:
|
|
107
|
+
"""Returns the global hooks directory in Lemming home."""
|
|
108
|
+
return get_lemming_home() / "hooks"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_project_dir(tasks_file: pathlib.Path) -> pathlib.Path:
|
|
112
|
+
"""Determines the isolated project directory for a given tasks file.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
tasks_file: Path to the tasks YAML file.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
A pathlib.Path to the isolated directory where project logs and state
|
|
119
|
+
should be stored.
|
|
120
|
+
"""
|
|
121
|
+
tasks_file_abs = tasks_file.resolve()
|
|
122
|
+
lemming_home = get_lemming_home()
|
|
123
|
+
|
|
124
|
+
# If the tasks file is already inside lemming home, its parent IS the
|
|
125
|
+
# project dir.
|
|
126
|
+
if tasks_file_abs.parent.parent == lemming_home:
|
|
127
|
+
return tasks_file_abs.parent
|
|
128
|
+
|
|
129
|
+
# Otherwise, hash the absolute path of the tasks file to get a unique
|
|
130
|
+
# project dir.
|
|
131
|
+
path_hash = hashlib.sha256(str(tasks_file_abs).encode()).hexdigest()[:12]
|
|
132
|
+
return lemming_home / path_hash
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def get_tasks_file_for_dir(directory: pathlib.Path) -> pathlib.Path:
|
|
136
|
+
"""Returns the tasks file location for a given project directory.
|
|
137
|
+
|
|
138
|
+
Checks for a local `tasks.yml` first, then falls back to an isolated
|
|
139
|
+
project tasks file in the Lemming home directory.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
directory: The resolved absolute path to the project directory.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
A pathlib.Path to the tasks file for that directory.
|
|
146
|
+
"""
|
|
147
|
+
local_tasks = directory / "tasks.yml"
|
|
148
|
+
if local_tasks.exists():
|
|
149
|
+
return local_tasks
|
|
150
|
+
|
|
151
|
+
path_hash = hashlib.sha256(str(directory).encode()).hexdigest()[:12]
|
|
152
|
+
return get_lemming_home() / path_hash / "tasks.yml"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def get_default_tasks_file() -> pathlib.Path:
|
|
156
|
+
"""Returns the default tasks file location based on the current directory.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
A pathlib.Path to the default tasks file.
|
|
160
|
+
"""
|
|
161
|
+
return get_tasks_file_for_dir(pathlib.Path.cwd().resolve())
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def get_working_dir(tasks_file: pathlib.Path) -> pathlib.Path:
|
|
165
|
+
"""Returns the intended working directory for a tasks file.
|
|
166
|
+
|
|
167
|
+
If the tasks file is NOT in the lemming home directory, its parent
|
|
168
|
+
is assumed to be the working directory.
|
|
169
|
+
|
|
170
|
+
If it IS in the lemming home directory, we return the current
|
|
171
|
+
working directory as a fallback.
|
|
172
|
+
"""
|
|
173
|
+
tasks_file_abs = tasks_file.resolve()
|
|
174
|
+
lemming_home = get_lemming_home()
|
|
175
|
+
|
|
176
|
+
if lemming_home in tasks_file_abs.parents:
|
|
177
|
+
# It's an isolated tasks file. We don't know the original source dir
|
|
178
|
+
# unless it was passed to us or stored in the file.
|
|
179
|
+
# For now, return CWD.
|
|
180
|
+
return pathlib.Path.cwd().resolve()
|
|
181
|
+
|
|
182
|
+
# It's a local tasks.yml file. Its parent is the project root.
|
|
183
|
+
return tasks_file_abs.parent
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def get_log_file(tasks_file: pathlib.Path, task_id: str) -> pathlib.Path:
|
|
187
|
+
"""Returns the log file path for a specific task.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
tasks_file: Path to the tasks YAML file associated with the task.
|
|
191
|
+
task_id: The unique task ID.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
A pathlib.Path to the log file for the given task.
|
|
195
|
+
"""
|
|
196
|
+
project_dir = get_project_dir(tasks_file)
|
|
197
|
+
project_dir.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
return project_dir / f"{task_id}-runner.log"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@functools.cache
|
|
202
|
+
def in_git_repo() -> bool:
|
|
203
|
+
"""Check if the current directory is inside a git repository.
|
|
204
|
+
|
|
205
|
+
The result is cached after the first call; use
|
|
206
|
+
`in_git_repo.cache_clear()` to reset it.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
True if inside a git repository, False otherwise.
|
|
210
|
+
"""
|
|
211
|
+
try:
|
|
212
|
+
return (
|
|
213
|
+
subprocess.run(
|
|
214
|
+
["git", "rev-parse", "--git-dir"],
|
|
215
|
+
capture_output=True,
|
|
216
|
+
check=False,
|
|
217
|
+
).returncode
|
|
218
|
+
== 0
|
|
219
|
+
)
|
|
220
|
+
except Exception:
|
|
221
|
+
return False
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def is_ignored(path: pathlib.Path) -> bool:
|
|
225
|
+
"""Check if a given path is ignored by git.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
path: The path to check for git-ignore status.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
True if the path is ignored by git, False otherwise.
|
|
232
|
+
"""
|
|
233
|
+
if not in_git_repo():
|
|
234
|
+
return False
|
|
235
|
+
try:
|
|
236
|
+
return (
|
|
237
|
+
subprocess.run(
|
|
238
|
+
["git", "check-ignore", "-q", str(path)],
|
|
239
|
+
capture_output=True,
|
|
240
|
+
check=False,
|
|
241
|
+
).returncode
|
|
242
|
+
== 0
|
|
243
|
+
)
|
|
244
|
+
except Exception:
|
|
245
|
+
return False
|