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
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()
lemming/main.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for running the Lemming CLI as a module."""
2
+
3
+ from .cli import cli
4
+
5
+ if __name__ == "__main__":
6
+ cli()