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
lemming/cli_test.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
import click.testing
|
|
4
|
+
|
|
5
|
+
from lemming import cli
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestCLIProxy(unittest.TestCase):
|
|
9
|
+
def setUp(self):
|
|
10
|
+
self.cli_runner = click.testing.CliRunner()
|
|
11
|
+
|
|
12
|
+
def test_cli_proxy_help(self):
|
|
13
|
+
# Verify that the proxy re-export works correctly
|
|
14
|
+
result = self.cli_runner.invoke(cli.cli, ["--help"])
|
|
15
|
+
self.assertEqual(result.exit_code, 0)
|
|
16
|
+
self.assertIn(
|
|
17
|
+
"Lemming: An autonomous, iterative task runner", result.output
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
if __name__ == "__main__":
|
|
22
|
+
unittest.main()
|
lemming/conftest.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Shared pytest fixtures for the lemming test suite."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture(autouse=True, scope="session")
|
|
9
|
+
def setup_lemming_home(tmp_path_factory):
|
|
10
|
+
"""Set up a temporary LEMMING_HOME for the entire test session."""
|
|
11
|
+
tmp_home = tmp_path_factory.mktemp("lemming_home")
|
|
12
|
+
os.environ["LEMMING_HOME"] = str(tmp_home)
|
|
13
|
+
return tmp_home
|
lemming/hooks.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Discovery and execution of orchestrator hooks for finished tasks."""
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
import traceback
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from . import prompts, runner, tasks
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run_hooks(
|
|
12
|
+
tasks_file: pathlib.Path,
|
|
13
|
+
task_id: str,
|
|
14
|
+
runner_name: str,
|
|
15
|
+
yolo: bool,
|
|
16
|
+
runner_args: tuple,
|
|
17
|
+
no_defaults: bool,
|
|
18
|
+
verbose: bool,
|
|
19
|
+
hooks: list[str] | None = None,
|
|
20
|
+
working_dir: pathlib.Path | None = None,
|
|
21
|
+
final_status: tasks.TaskStatus | None = None,
|
|
22
|
+
time_limit: int = 0,
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Discovers and executes orchestrator hooks for a finished task.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
tasks_file: Path to the tasks YAML file.
|
|
28
|
+
task_id: ID of the task the hooks run against.
|
|
29
|
+
runner_name: Name of the runner CLI used to execute the hooks.
|
|
30
|
+
yolo: Whether to run the runner in unattended (yolo) mode.
|
|
31
|
+
runner_args: Extra arguments forwarded to the runner CLI.
|
|
32
|
+
no_defaults: Whether to skip the runner's default arguments.
|
|
33
|
+
verbose: Whether to echo prompts and hook diagnostics.
|
|
34
|
+
hooks: Explicit list of hooks to run. If None, uses config.hooks.
|
|
35
|
+
working_dir: Working directory for the hook runner processes.
|
|
36
|
+
final_status: If provided, mark the task with this status after hooks.
|
|
37
|
+
time_limit: Time limit in minutes for each hook run (0 disables it).
|
|
38
|
+
"""
|
|
39
|
+
data = tasks.load_tasks(tasks_file)
|
|
40
|
+
task = next((t for t in data.tasks if t.id == task_id), None)
|
|
41
|
+
if not task:
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
# Use provided hooks or fall back to configuration
|
|
45
|
+
active_hooks = hooks if hooks is not None else data.config.hooks
|
|
46
|
+
if active_hooks is None:
|
|
47
|
+
active_hooks = prompts.list_hooks(tasks_file)
|
|
48
|
+
|
|
49
|
+
if final_status == tasks.TaskStatus.FAILED:
|
|
50
|
+
if "roadmap" in active_hooks:
|
|
51
|
+
active_hooks = ["roadmap"]
|
|
52
|
+
else:
|
|
53
|
+
active_hooks = []
|
|
54
|
+
|
|
55
|
+
if not active_hooks:
|
|
56
|
+
if final_status:
|
|
57
|
+
tasks.update_task(
|
|
58
|
+
tasks_file, task_id, status=final_status, force=True
|
|
59
|
+
)
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
for hook_name in active_hooks:
|
|
63
|
+
# Reload tasks every time to ensure each hook sees progress from
|
|
64
|
+
# previous hooks
|
|
65
|
+
data = tasks.load_tasks(tasks_file)
|
|
66
|
+
task = next((t for t in data.tasks if t.id == task_id), None)
|
|
67
|
+
if not task:
|
|
68
|
+
if verbose:
|
|
69
|
+
click.echo(
|
|
70
|
+
f"Task {task_id} not found during hook '{hook_name}' run."
|
|
71
|
+
)
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
prompt = prompts.prepare_hook_prompt(
|
|
76
|
+
hook_name, data, task, tasks_file
|
|
77
|
+
)
|
|
78
|
+
except FileNotFoundError:
|
|
79
|
+
if verbose:
|
|
80
|
+
click.echo(f"Hook '{hook_name}' prompt not found, skipping.")
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
if verbose:
|
|
84
|
+
click.secho(
|
|
85
|
+
f"\n=== Hook: {hook_name} Prompt ===", fg="magenta", bold=True
|
|
86
|
+
)
|
|
87
|
+
click.echo(prompt)
|
|
88
|
+
click.secho("========================\n", fg="magenta", bold=True)
|
|
89
|
+
|
|
90
|
+
cmd = runner.build_runner_command(
|
|
91
|
+
runner_name,
|
|
92
|
+
prompt,
|
|
93
|
+
yolo,
|
|
94
|
+
runner_args,
|
|
95
|
+
no_defaults,
|
|
96
|
+
verbose=verbose,
|
|
97
|
+
time_limit=time_limit,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
# Append hooks to the main runner log for a unified execution trace.
|
|
102
|
+
returncode, stdout, stderr = runner.run_with_heartbeat(
|
|
103
|
+
cmd,
|
|
104
|
+
tasks_file,
|
|
105
|
+
task_id,
|
|
106
|
+
verbose,
|
|
107
|
+
echo_fn=lambda line: click.echo(line, nl=False),
|
|
108
|
+
header=f"Hook: {hook_name}",
|
|
109
|
+
cwd=working_dir,
|
|
110
|
+
time_limit=time_limit,
|
|
111
|
+
)
|
|
112
|
+
if verbose:
|
|
113
|
+
if returncode != 0:
|
|
114
|
+
click.echo(
|
|
115
|
+
f"Hook '{hook_name}' exited with code {returncode}."
|
|
116
|
+
)
|
|
117
|
+
except Exception as e:
|
|
118
|
+
click.echo(f"Hook '{hook_name}' error: {e}")
|
|
119
|
+
|
|
120
|
+
# Finally mark the task as completed or failed if requested.
|
|
121
|
+
# But first check whether a hook already changed the task (e.g. the
|
|
122
|
+
# roadmap hook reset a failed task for a new approach). If the task
|
|
123
|
+
# is no longer IN_PROGRESS, a hook already intervened — skip
|
|
124
|
+
# finalization so we don't overwrite the recovery.
|
|
125
|
+
if final_status:
|
|
126
|
+
try:
|
|
127
|
+
data = tasks.load_tasks(tasks_file)
|
|
128
|
+
current = next((t for t in data.tasks if t.id == task_id), None)
|
|
129
|
+
if current and current.status != tasks.TaskStatus.IN_PROGRESS:
|
|
130
|
+
return
|
|
131
|
+
tasks.update_task(
|
|
132
|
+
tasks_file, task_id, status=final_status, force=True
|
|
133
|
+
)
|
|
134
|
+
except tasks.TaskNotFoundError:
|
|
135
|
+
# Task may have been deleted by the orchestrator (e.g. after
|
|
136
|
+
# a failure it decided to take a different approach). This
|
|
137
|
+
# is expected and not an error worth a traceback.
|
|
138
|
+
click.echo(
|
|
139
|
+
f"Task {task_id} was removed before it could be "
|
|
140
|
+
"finalized — the orchestrator likely restructured "
|
|
141
|
+
"the plan."
|
|
142
|
+
)
|
|
143
|
+
except Exception as e:
|
|
144
|
+
click.echo(f"Error finalizing task {task_id}: {e}")
|
|
145
|
+
traceback.print_exc()
|
lemming/hooks_test.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
import shutil
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
import unittest.mock
|
|
6
|
+
|
|
7
|
+
from lemming import tasks
|
|
8
|
+
from lemming.hooks import run_hooks
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestHooks(unittest.TestCase):
|
|
12
|
+
def setUp(self):
|
|
13
|
+
self.test_dir = tempfile.mkdtemp()
|
|
14
|
+
self.test_tasks_file = pathlib.Path(self.test_dir) / "tasks_test.yml"
|
|
15
|
+
|
|
16
|
+
# Scaffold a valid file
|
|
17
|
+
data = tasks.Roadmap(
|
|
18
|
+
context="Initial context",
|
|
19
|
+
tasks=[
|
|
20
|
+
tasks.Task(
|
|
21
|
+
id="12345678",
|
|
22
|
+
description="Initial Task",
|
|
23
|
+
status=tasks.TaskStatus.PENDING,
|
|
24
|
+
attempts=0,
|
|
25
|
+
progress=[],
|
|
26
|
+
)
|
|
27
|
+
],
|
|
28
|
+
config=tasks.RoadmapConfig(
|
|
29
|
+
retries=3, runner="agy", hooks=["roadmap"]
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
tasks.save_tasks(self.test_tasks_file, data)
|
|
33
|
+
|
|
34
|
+
def tearDown(self):
|
|
35
|
+
shutil.rmtree(self.test_dir)
|
|
36
|
+
|
|
37
|
+
@unittest.mock.patch("lemming.runner.run_with_heartbeat")
|
|
38
|
+
@unittest.mock.patch("lemming.prompts.list_hooks")
|
|
39
|
+
@unittest.mock.patch("lemming.prompts.prepare_hook_prompt")
|
|
40
|
+
def test_run_hooks_success(self, mock_prepare, mock_list, mock_run):
|
|
41
|
+
mock_list.return_value = ["roadmap"]
|
|
42
|
+
mock_prepare.return_value = "Hook Prompt"
|
|
43
|
+
mock_run.return_value = (0, "stdout", "")
|
|
44
|
+
|
|
45
|
+
# Task must be IN_PROGRESS for finalization to apply
|
|
46
|
+
tasks.update_task(
|
|
47
|
+
self.test_tasks_file,
|
|
48
|
+
"12345678",
|
|
49
|
+
status=tasks.TaskStatus.IN_PROGRESS,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
run_hooks(
|
|
53
|
+
self.test_tasks_file,
|
|
54
|
+
"12345678",
|
|
55
|
+
"agy",
|
|
56
|
+
yolo=True,
|
|
57
|
+
runner_args=(),
|
|
58
|
+
no_defaults=False,
|
|
59
|
+
verbose=True,
|
|
60
|
+
final_status=tasks.TaskStatus.COMPLETED,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
self.assertTrue(mock_run.called)
|
|
64
|
+
data = tasks.load_tasks(self.test_tasks_file)
|
|
65
|
+
self.assertEqual(data.tasks[0].status, tasks.TaskStatus.COMPLETED)
|
|
66
|
+
|
|
67
|
+
@unittest.mock.patch("lemming.runner.run_with_heartbeat")
|
|
68
|
+
def test_run_hooks_no_hooks(self, mock_run):
|
|
69
|
+
run_hooks(
|
|
70
|
+
self.test_tasks_file,
|
|
71
|
+
"12345678",
|
|
72
|
+
"agy",
|
|
73
|
+
yolo=True,
|
|
74
|
+
runner_args=(),
|
|
75
|
+
no_defaults=False,
|
|
76
|
+
verbose=True,
|
|
77
|
+
hooks=[],
|
|
78
|
+
final_status=tasks.TaskStatus.COMPLETED,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
self.assertFalse(mock_run.called)
|
|
82
|
+
data = tasks.load_tasks(self.test_tasks_file)
|
|
83
|
+
self.assertEqual(data.tasks[0].status, tasks.TaskStatus.COMPLETED)
|
|
84
|
+
|
|
85
|
+
@unittest.mock.patch("lemming.hooks.prompts.prepare_hook_prompt")
|
|
86
|
+
@unittest.mock.patch("lemming.runner.run_with_heartbeat")
|
|
87
|
+
def test_run_hooks_failure_filters_hooks(self, mock_run, mock_prepare):
|
|
88
|
+
mock_run.return_value = (0, "stdout", "")
|
|
89
|
+
mock_prepare.return_value = "Mock Prompt"
|
|
90
|
+
|
|
91
|
+
# Task must be IN_PROGRESS for finalization to apply
|
|
92
|
+
tasks.update_task(
|
|
93
|
+
self.test_tasks_file,
|
|
94
|
+
"12345678",
|
|
95
|
+
status=tasks.TaskStatus.IN_PROGRESS,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
run_hooks(
|
|
99
|
+
self.test_tasks_file,
|
|
100
|
+
"12345678",
|
|
101
|
+
"agy",
|
|
102
|
+
yolo=True,
|
|
103
|
+
runner_args=(),
|
|
104
|
+
no_defaults=False,
|
|
105
|
+
verbose=True,
|
|
106
|
+
hooks=["readability", "roadmap", "testing"],
|
|
107
|
+
final_status=tasks.TaskStatus.FAILED,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# It should only run 'roadmap' hook
|
|
111
|
+
self.assertEqual(mock_prepare.call_count, 1)
|
|
112
|
+
self.assertEqual(mock_prepare.call_args[0][0], "roadmap")
|
|
113
|
+
|
|
114
|
+
@unittest.mock.patch("lemming.hooks.prompts.prepare_hook_prompt")
|
|
115
|
+
@unittest.mock.patch("lemming.runner.run_with_heartbeat")
|
|
116
|
+
def test_run_hooks_skips_finalization_when_healed(
|
|
117
|
+
self, mock_run, mock_prepare
|
|
118
|
+
):
|
|
119
|
+
"""If a hook resets the task (heals it), skip finalization."""
|
|
120
|
+
mock_prepare.return_value = "Mock Prompt"
|
|
121
|
+
|
|
122
|
+
# Task must be IN_PROGRESS for hooks to run
|
|
123
|
+
tasks.update_task(
|
|
124
|
+
self.test_tasks_file,
|
|
125
|
+
"12345678",
|
|
126
|
+
status=tasks.TaskStatus.IN_PROGRESS,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Simulate the hook resetting the task during execution
|
|
130
|
+
def hook_resets_task(*args, **kwargs):
|
|
131
|
+
tasks.reset_task(self.test_tasks_file, "12345678")
|
|
132
|
+
return (0, "stdout", "")
|
|
133
|
+
|
|
134
|
+
mock_run.side_effect = hook_resets_task
|
|
135
|
+
|
|
136
|
+
run_hooks(
|
|
137
|
+
self.test_tasks_file,
|
|
138
|
+
"12345678",
|
|
139
|
+
"agy",
|
|
140
|
+
yolo=True,
|
|
141
|
+
runner_args=(),
|
|
142
|
+
no_defaults=False,
|
|
143
|
+
verbose=True,
|
|
144
|
+
hooks=["roadmap"],
|
|
145
|
+
final_status=tasks.TaskStatus.FAILED,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Task should remain PENDING (healed), not FAILED
|
|
149
|
+
data = tasks.load_tasks(self.test_tasks_file)
|
|
150
|
+
self.assertEqual(data.tasks[0].status, tasks.TaskStatus.PENDING)
|
|
151
|
+
self.assertEqual(data.tasks[0].attempts, 0)
|
|
152
|
+
|
|
153
|
+
@unittest.mock.patch("lemming.hooks.prompts.prepare_hook_prompt")
|
|
154
|
+
@unittest.mock.patch("lemming.runner.run_with_heartbeat")
|
|
155
|
+
def test_run_hooks_reloads_tasks(self, mock_run, mock_prepare):
|
|
156
|
+
# Create a real Roadmap object to return
|
|
157
|
+
real_data = tasks.load_tasks(self.test_tasks_file)
|
|
158
|
+
mock_run.return_value = (0, "stdout", "")
|
|
159
|
+
mock_prepare.return_value = "Mock Prompt"
|
|
160
|
+
|
|
161
|
+
with unittest.mock.patch(
|
|
162
|
+
"lemming.hooks.tasks.load_tasks", return_value=real_data
|
|
163
|
+
) as mock_load:
|
|
164
|
+
run_hooks(
|
|
165
|
+
self.test_tasks_file,
|
|
166
|
+
"12345678",
|
|
167
|
+
"agy",
|
|
168
|
+
yolo=True,
|
|
169
|
+
runner_args=(),
|
|
170
|
+
no_defaults=False,
|
|
171
|
+
verbose=True,
|
|
172
|
+
hooks=["h1", "h2"],
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# 1 initial load + 2 hook loads = 3
|
|
176
|
+
self.assertEqual(mock_load.call_count, 3)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
unittest.main()
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
import shutil
|
|
3
|
+
import tempfile
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
import unittest
|
|
7
|
+
import unittest.mock
|
|
8
|
+
|
|
9
|
+
from lemming import models, orchestrator, runner, tasks
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestIntegration(unittest.TestCase):
|
|
13
|
+
def setUp(self):
|
|
14
|
+
self.test_dir = tempfile.mkdtemp()
|
|
15
|
+
self.test_tasks_file = pathlib.Path(self.test_dir) / "tasks_test.yml"
|
|
16
|
+
|
|
17
|
+
# Scaffold a valid file with one task
|
|
18
|
+
self.initial_data = models.Roadmap(
|
|
19
|
+
context="Initial context",
|
|
20
|
+
tasks=[
|
|
21
|
+
models.Task(
|
|
22
|
+
id="task1",
|
|
23
|
+
description="Task 1",
|
|
24
|
+
status=models.TaskStatus.PENDING,
|
|
25
|
+
attempts=0,
|
|
26
|
+
progress=[],
|
|
27
|
+
)
|
|
28
|
+
],
|
|
29
|
+
config=models.RoadmapConfig(retries=3, runner="true"),
|
|
30
|
+
)
|
|
31
|
+
tasks.save_tasks(self.test_tasks_file, self.initial_data)
|
|
32
|
+
|
|
33
|
+
def tearDown(self):
|
|
34
|
+
shutil.rmtree(self.test_dir)
|
|
35
|
+
|
|
36
|
+
def test_heartbeat_updates_during_execution(self):
|
|
37
|
+
"""Verify a long-running task updates its heartbeat periodically."""
|
|
38
|
+
# We need to speed up the threshold for testing
|
|
39
|
+
with (
|
|
40
|
+
unittest.mock.patch("lemming.tasks.STALE_THRESHOLD", 2),
|
|
41
|
+
unittest.mock.patch("lemming.persistence.STALE_THRESHOLD", 2),
|
|
42
|
+
unittest.mock.patch("lemming.tasks.lifecycle.STALE_THRESHOLD", 2),
|
|
43
|
+
):
|
|
44
|
+
# 0. Mark task as in progress first, as
|
|
45
|
+
# runner.run_with_heartbeat expects it
|
|
46
|
+
tasks.mark_task_in_progress(self.test_tasks_file, "task1")
|
|
47
|
+
|
|
48
|
+
# Start a task that runs for 3 seconds (longer than threshold)
|
|
49
|
+
cmd = ["sleep", "3"]
|
|
50
|
+
|
|
51
|
+
# Run in a separate thread so we can check the file
|
|
52
|
+
def run_task():
|
|
53
|
+
runner.run_with_heartbeat(
|
|
54
|
+
cmd, self.test_tasks_file, "task1", verbose=False
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
t = threading.Thread(target=run_task)
|
|
58
|
+
t.start()
|
|
59
|
+
|
|
60
|
+
# Wait a bit for it to start and set initial heartbeat
|
|
61
|
+
time.sleep(0.5)
|
|
62
|
+
data = tasks.load_tasks(self.test_tasks_file)
|
|
63
|
+
h1 = data.tasks[0].last_heartbeat
|
|
64
|
+
self.assertIsNotNone(h1, "Initial heartbeat should be set")
|
|
65
|
+
|
|
66
|
+
# Wait for next heartbeat (interval is threshold // 2 = 1s)
|
|
67
|
+
time.sleep(1.5)
|
|
68
|
+
data = tasks.load_tasks(self.test_tasks_file)
|
|
69
|
+
h2 = data.tasks[0].last_heartbeat
|
|
70
|
+
self.assertIsNotNone(h2, "Second heartbeat should be set")
|
|
71
|
+
self.assertGreater(h2, h1, "Heartbeat should increase over time")
|
|
72
|
+
|
|
73
|
+
t.join()
|
|
74
|
+
|
|
75
|
+
def test_task_reclaimed_if_heartbeat_stops(self):
|
|
76
|
+
"""Verify a task can be reclaimed if its runner stops heartbeats."""
|
|
77
|
+
with (
|
|
78
|
+
unittest.mock.patch("lemming.tasks.STALE_THRESHOLD", 1),
|
|
79
|
+
unittest.mock.patch("lemming.persistence.STALE_THRESHOLD", 1),
|
|
80
|
+
unittest.mock.patch("lemming.tasks.lifecycle.STALE_THRESHOLD", 1),
|
|
81
|
+
):
|
|
82
|
+
# 1. Claim the task manually with a PID that doesn't exist
|
|
83
|
+
# Note: is_pid_alive will return False for 999999 (likely)
|
|
84
|
+
tasks.claim_task(self.test_tasks_file, "task1", pid=999999)
|
|
85
|
+
|
|
86
|
+
data = tasks.load_tasks(self.test_tasks_file)
|
|
87
|
+
self.assertEqual(
|
|
88
|
+
data.tasks[0].status, models.TaskStatus.IN_PROGRESS
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# 2. Wait for it to become stale
|
|
92
|
+
time.sleep(1.1)
|
|
93
|
+
|
|
94
|
+
# 3. Try to claim it again (as if another orchestrator is running)
|
|
95
|
+
# claim_task should allow reclaiming if stale
|
|
96
|
+
task = tasks.claim_task(self.test_tasks_file, "task1", pid=88888)
|
|
97
|
+
self.assertIsNotNone(
|
|
98
|
+
task, "Task should be reclaimable after heartbeat timeout"
|
|
99
|
+
)
|
|
100
|
+
self.assertEqual(task.pid, 88888)
|
|
101
|
+
|
|
102
|
+
def test_orchestrator_retries_on_runner_failure(self):
|
|
103
|
+
"""Verify the orchestrator retries if the runner does not succeed."""
|
|
104
|
+
# Mocking time.sleep to speed up tests
|
|
105
|
+
with unittest.mock.patch("time.sleep", return_value=None):
|
|
106
|
+
# Configure roadmap with 2 retries
|
|
107
|
+
data = tasks.load_tasks(self.test_tasks_file)
|
|
108
|
+
data.config.retries = 2
|
|
109
|
+
data.config.runner = "true" # 'true' command just exits 0
|
|
110
|
+
tasks.save_tasks(self.test_tasks_file, data)
|
|
111
|
+
|
|
112
|
+
orchestrator.run_loop(
|
|
113
|
+
self.test_tasks_file,
|
|
114
|
+
verbose=False,
|
|
115
|
+
retry_delay=0,
|
|
116
|
+
yolo=True,
|
|
117
|
+
no_defaults=False,
|
|
118
|
+
runner_args=(),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# After 2 attempts it should be FAILED
|
|
122
|
+
data = tasks.load_tasks(self.test_tasks_file)
|
|
123
|
+
self.assertEqual(data.tasks[0].attempts, 2)
|
|
124
|
+
self.assertEqual(data.tasks[0].status, models.TaskStatus.FAILED)
|
|
125
|
+
|
|
126
|
+
def test_runner_terminates_if_reclaimed(self):
|
|
127
|
+
"""Verify the original runner terminates if its task is reclaimed."""
|
|
128
|
+
with (
|
|
129
|
+
unittest.mock.patch("lemming.tasks.STALE_THRESHOLD", 2),
|
|
130
|
+
unittest.mock.patch("lemming.persistence.STALE_THRESHOLD", 2),
|
|
131
|
+
unittest.mock.patch("lemming.tasks.lifecycle.STALE_THRESHOLD", 2),
|
|
132
|
+
):
|
|
133
|
+
# 0. Mark task as in progress
|
|
134
|
+
tasks.mark_task_in_progress(self.test_tasks_file, "task1")
|
|
135
|
+
|
|
136
|
+
# Start a long-running task
|
|
137
|
+
cmd = ["sleep", "10"]
|
|
138
|
+
|
|
139
|
+
def run_task():
|
|
140
|
+
runner.run_with_heartbeat(
|
|
141
|
+
cmd, self.test_tasks_file, "task1", verbose=True
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
t = threading.Thread(target=run_task)
|
|
145
|
+
t.start()
|
|
146
|
+
|
|
147
|
+
# Wait for it to start
|
|
148
|
+
time.sleep(0.5)
|
|
149
|
+
|
|
150
|
+
# 2. Reclaim the task manually by changing the PID in the file
|
|
151
|
+
# runner.py check:
|
|
152
|
+
# if not tasks.update_heartbeat(tasks_file, task_id):
|
|
153
|
+
# update_heartbeat returns True ONLY if task is IN_PROGRESS.
|
|
154
|
+
# Wait, update_heartbeat in lifecycle.py DOES NOT check PID if
|
|
155
|
+
# it matches! It just updates it.
|
|
156
|
+
|
|
157
|
+
# Let's re-read update_heartbeat:
|
|
158
|
+
# def update_heartbeat(tasks_file, task_id, pid=None):
|
|
159
|
+
# for task in data.tasks:
|
|
160
|
+
# if task.id == task_id:
|
|
161
|
+
# if task.status != models.TaskStatus.IN_PROGRESS:
|
|
162
|
+
# return False
|
|
163
|
+
# task.last_heartbeat = time.time()
|
|
164
|
+
# if pid is not None:
|
|
165
|
+
# task.pid = pid
|
|
166
|
+
# break
|
|
167
|
+
|
|
168
|
+
# Ah! It doesn't check if the PID matches. So if another process
|
|
169
|
+
# just calls update_heartbeat, it will succeed.
|
|
170
|
+
|
|
171
|
+
# BUT, mark_task_in_progress/claim_task will succeed if it's stale.
|
|
172
|
+
# And once it's COMPLETED or FAILED, update_heartbeat returns False.
|
|
173
|
+
|
|
174
|
+
# Let's simulate cancellation/completion by another process
|
|
175
|
+
with tasks.lock_tasks(self.test_tasks_file):
|
|
176
|
+
data = tasks.load_tasks(self.test_tasks_file)
|
|
177
|
+
data.tasks[0].status = models.TaskStatus.COMPLETED
|
|
178
|
+
tasks.save_tasks(self.test_tasks_file, data)
|
|
179
|
+
|
|
180
|
+
# Wait for the next heartbeat (threshold // 2 = 1s)
|
|
181
|
+
t.join(timeout=5)
|
|
182
|
+
self.assertFalse(
|
|
183
|
+
t.is_alive(),
|
|
184
|
+
"Runner thread should have terminated after task was "
|
|
185
|
+
"marked COMPLETED",
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def test_orchestrator_retries_on_runner_crash(self):
|
|
189
|
+
"""Verify the orchestrator retries if the runner exits non-zero."""
|
|
190
|
+
with unittest.mock.patch("time.sleep", return_value=None):
|
|
191
|
+
data = tasks.load_tasks(self.test_tasks_file)
|
|
192
|
+
data.config.retries = 3
|
|
193
|
+
data.config.runner = "false" # 'false' command exits with 1
|
|
194
|
+
tasks.save_tasks(self.test_tasks_file, data)
|
|
195
|
+
|
|
196
|
+
orchestrator.run_loop(
|
|
197
|
+
self.test_tasks_file,
|
|
198
|
+
verbose=False,
|
|
199
|
+
retry_delay=0,
|
|
200
|
+
yolo=True,
|
|
201
|
+
no_defaults=False,
|
|
202
|
+
runner_args=(),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
data = tasks.load_tasks(self.test_tasks_file)
|
|
206
|
+
# It should have attempted 3 times and then failed
|
|
207
|
+
self.assertEqual(data.tasks[0].attempts, 3)
|
|
208
|
+
self.assertEqual(data.tasks[0].status, models.TaskStatus.FAILED)
|
|
209
|
+
|
|
210
|
+
def test_orchestrator_stops_on_cancellation(self):
|
|
211
|
+
"""Verify the orchestrator stops if the runner exits with SIGTERM."""
|
|
212
|
+
with (
|
|
213
|
+
unittest.mock.patch("time.sleep", return_value=None),
|
|
214
|
+
unittest.mock.patch(
|
|
215
|
+
"lemming.runner.run_with_heartbeat", return_value=(-15, "", "")
|
|
216
|
+
),
|
|
217
|
+
):
|
|
218
|
+
data = tasks.load_tasks(self.test_tasks_file)
|
|
219
|
+
data.config.retries = 3
|
|
220
|
+
tasks.save_tasks(self.test_tasks_file, data)
|
|
221
|
+
|
|
222
|
+
# This should NOT loop 3 times. It should break immediately.
|
|
223
|
+
orchestrator.run_loop(
|
|
224
|
+
self.test_tasks_file,
|
|
225
|
+
verbose=False,
|
|
226
|
+
retry_delay=0,
|
|
227
|
+
yolo=True,
|
|
228
|
+
no_defaults=False,
|
|
229
|
+
runner_args=(),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
data = tasks.load_tasks(self.test_tasks_file)
|
|
233
|
+
# It should have only 1 attempt because it broke
|
|
234
|
+
self.assertEqual(data.tasks[0].attempts, 1)
|
|
235
|
+
# Status should be PENDING (as finish_task_attempt sets it if
|
|
236
|
+
# no requested_status)
|
|
237
|
+
self.assertEqual(data.tasks[0].status, models.TaskStatus.PENDING)
|
|
238
|
+
|
|
239
|
+
def test_claim_task_race_condition(self):
|
|
240
|
+
"""Verify that claim_task is atomic and prevents double claiming."""
|
|
241
|
+
# Mock is_pid_alive to return True so that even if we use fake PIDs,
|
|
242
|
+
# they are not considered dead and thus not reclaimable immediately.
|
|
243
|
+
with unittest.mock.patch(
|
|
244
|
+
"lemming.tasks.lifecycle.is_pid_alive", return_value=True
|
|
245
|
+
):
|
|
246
|
+
# We'll use multiple threads to try to claim the same task
|
|
247
|
+
results = []
|
|
248
|
+
|
|
249
|
+
def try_claim(pid):
|
|
250
|
+
res = tasks.claim_task(self.test_tasks_file, "task1", pid=pid)
|
|
251
|
+
if res:
|
|
252
|
+
results.append(pid)
|
|
253
|
+
|
|
254
|
+
threads = []
|
|
255
|
+
for i in range(1, 11): # Use 1 to 10
|
|
256
|
+
t = threading.Thread(target=try_claim, args=(i,))
|
|
257
|
+
threads.append(t)
|
|
258
|
+
|
|
259
|
+
for t in threads:
|
|
260
|
+
t.start()
|
|
261
|
+
for t in threads:
|
|
262
|
+
t.join()
|
|
263
|
+
|
|
264
|
+
# Only one should have succeeded
|
|
265
|
+
self.assertEqual(len(results), 1)
|
|
266
|
+
data = tasks.load_tasks(self.test_tasks_file)
|
|
267
|
+
self.assertEqual(
|
|
268
|
+
data.tasks[0].status, models.TaskStatus.IN_PROGRESS
|
|
269
|
+
)
|
|
270
|
+
self.assertIn(data.tasks[0].pid, results)
|
|
271
|
+
|
|
272
|
+
def test_reclaim_if_pid_dead(self):
|
|
273
|
+
"""Verify a task with a dead PID is immediately reclaimable.
|
|
274
|
+
|
|
275
|
+
This holds even if the heartbeat is fresh.
|
|
276
|
+
"""
|
|
277
|
+
with unittest.mock.patch(
|
|
278
|
+
"lemming.tasks.lifecycle.is_pid_alive", return_value=False
|
|
279
|
+
):
|
|
280
|
+
# 1. Claim it with some PID
|
|
281
|
+
tasks.claim_task(self.test_tasks_file, "task1", pid=12345)
|
|
282
|
+
|
|
283
|
+
data = tasks.load_tasks(self.test_tasks_file)
|
|
284
|
+
self.assertEqual(
|
|
285
|
+
data.tasks[0].status, models.TaskStatus.IN_PROGRESS
|
|
286
|
+
)
|
|
287
|
+
self.assertEqual(data.tasks[0].pid, 12345)
|
|
288
|
+
|
|
289
|
+
# 2. Heartbeat is fresh, but is_pid_alive is mocked to False
|
|
290
|
+
# Try to claim it again with another PID
|
|
291
|
+
task = tasks.claim_task(self.test_tasks_file, "task1", pid=67890)
|
|
292
|
+
self.assertIsNotNone(
|
|
293
|
+
task, "Task should be reclaimable if PID is dead"
|
|
294
|
+
)
|
|
295
|
+
self.assertEqual(task.pid, 67890)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
if __name__ == "__main__":
|
|
299
|
+
unittest.main()
|