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/main_test.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
import shutil
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
import click.testing
|
|
7
|
+
|
|
8
|
+
from lemming import main, tasks
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestMain(unittest.TestCase):
|
|
12
|
+
def setUp(self):
|
|
13
|
+
self.cli_runner = click.testing.CliRunner()
|
|
14
|
+
self.test_dir = tempfile.mkdtemp()
|
|
15
|
+
self.test_tasks_file = pathlib.Path(self.test_dir) / "tasks_test.yml"
|
|
16
|
+
self.base_args = ["--tasks-file", str(self.test_tasks_file)]
|
|
17
|
+
|
|
18
|
+
# Scaffold a valid file
|
|
19
|
+
data = tasks.Roadmap(
|
|
20
|
+
context="Initial context",
|
|
21
|
+
tasks=[
|
|
22
|
+
tasks.Task(
|
|
23
|
+
id="12345678",
|
|
24
|
+
description="Initial Task",
|
|
25
|
+
status=tasks.TaskStatus.PENDING,
|
|
26
|
+
attempts=0,
|
|
27
|
+
progress=[],
|
|
28
|
+
)
|
|
29
|
+
],
|
|
30
|
+
)
|
|
31
|
+
tasks.save_tasks(self.test_tasks_file, data)
|
|
32
|
+
|
|
33
|
+
def tearDown(self):
|
|
34
|
+
shutil.rmtree(self.test_dir)
|
|
35
|
+
|
|
36
|
+
def test_main_cli_entry_point(self):
|
|
37
|
+
# Verify that main.cli is accessible and works (it's imported from .cli)
|
|
38
|
+
result = self.cli_runner.invoke(main.cli, self.base_args + ["status"])
|
|
39
|
+
self.assertEqual(result.exit_code, 0)
|
|
40
|
+
self.assertIn("Initial Task", result.output)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
unittest.main()
|
lemming/models.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Pydantic models for tasks, roadmap state, and runner configuration."""
|
|
2
|
+
|
|
3
|
+
import enum
|
|
4
|
+
import functools
|
|
5
|
+
import shutil
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import pydantic
|
|
9
|
+
|
|
10
|
+
# Runner CLIs the orchestrator knows how to drive, in default-preference order.
|
|
11
|
+
KNOWN_RUNNERS = ("agy", "aider", "claude", "codex")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@functools.cache
|
|
15
|
+
def detect_default_runner() -> str:
|
|
16
|
+
"""Returns the first known runner CLI found on PATH, falling back to "agy".
|
|
17
|
+
|
|
18
|
+
Only used when no runner is persisted in the tasks file; an explicit
|
|
19
|
+
(user-set) runner always takes precedence and remains sticky.
|
|
20
|
+
"""
|
|
21
|
+
for name in KNOWN_RUNNERS:
|
|
22
|
+
if shutil.which(name):
|
|
23
|
+
return name
|
|
24
|
+
return KNOWN_RUNNERS[0]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TaskNotFoundError(ValueError):
|
|
28
|
+
"""Raised when a task ID cannot be found in the roadmap."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TaskStatus(enum.StrEnum):
|
|
32
|
+
"""Enumeration of possible task statuses."""
|
|
33
|
+
|
|
34
|
+
PENDING = "pending"
|
|
35
|
+
IN_PROGRESS = "in_progress"
|
|
36
|
+
COMPLETED = "completed"
|
|
37
|
+
FAILED = "failed"
|
|
38
|
+
CANCELLED = "cancelled"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Task(pydantic.BaseModel):
|
|
42
|
+
"""Represents a single task in the roadmap."""
|
|
43
|
+
|
|
44
|
+
id: str
|
|
45
|
+
description: str
|
|
46
|
+
status: TaskStatus = TaskStatus.PENDING
|
|
47
|
+
attempts: int = 0
|
|
48
|
+
progress: list[str] = pydantic.Field(default_factory=list)
|
|
49
|
+
runner: str | None = None
|
|
50
|
+
completed_at: float | None = None
|
|
51
|
+
started_at: float | None = None
|
|
52
|
+
last_started_at: float | None = None
|
|
53
|
+
created_at: float = pydantic.Field(default_factory=time.time)
|
|
54
|
+
run_time: float = 0.0
|
|
55
|
+
pid: int | None = None
|
|
56
|
+
last_heartbeat: float | None = None
|
|
57
|
+
has_runner_log: bool = False
|
|
58
|
+
parent: str | None = None
|
|
59
|
+
parent_tasks_file: str | None = None
|
|
60
|
+
index: int | None = pydantic.Field(default=None)
|
|
61
|
+
requested_status: TaskStatus | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class RoadmapConfig(pydantic.BaseModel):
|
|
65
|
+
"""Configuration for the roadmap execution loop."""
|
|
66
|
+
|
|
67
|
+
retries: int = 3
|
|
68
|
+
runner: str = pydantic.Field(default_factory=detect_default_runner)
|
|
69
|
+
hooks: list[str] | None = None
|
|
70
|
+
time_limit: int = 60
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Roadmap(pydantic.BaseModel):
|
|
74
|
+
"""Represents the entire roadmap state."""
|
|
75
|
+
|
|
76
|
+
context: str = ""
|
|
77
|
+
tasks: list[Task] = pydantic.Field(default_factory=list)
|
|
78
|
+
config: RoadmapConfig = pydantic.Field(default_factory=RoadmapConfig)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ProjectData(pydantic.BaseModel):
|
|
82
|
+
"""Represents the project data returned by the API."""
|
|
83
|
+
|
|
84
|
+
context: str
|
|
85
|
+
tasks: list[Task]
|
|
86
|
+
config: RoadmapConfig
|
|
87
|
+
cwd: str
|
|
88
|
+
loop_running: bool
|
lemming/models_test.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import pydantic
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from lemming import models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_task_status_values():
|
|
8
|
+
assert models.TaskStatus.PENDING == "pending"
|
|
9
|
+
assert models.TaskStatus.IN_PROGRESS == "in_progress"
|
|
10
|
+
assert models.TaskStatus.COMPLETED == "completed"
|
|
11
|
+
assert models.TaskStatus.FAILED == "failed"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_task_model_defaults():
|
|
15
|
+
task = models.Task(id="123", description="Test")
|
|
16
|
+
assert task.status == models.TaskStatus.PENDING
|
|
17
|
+
assert task.attempts == 0
|
|
18
|
+
assert task.progress == []
|
|
19
|
+
assert task.run_time == 0.0
|
|
20
|
+
assert task.created_at > 0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_task_model_validation():
|
|
24
|
+
# description is required
|
|
25
|
+
with pytest.raises(pydantic.ValidationError):
|
|
26
|
+
models.Task(id="123")
|
|
27
|
+
|
|
28
|
+
# id is required
|
|
29
|
+
with pytest.raises(pydantic.ValidationError):
|
|
30
|
+
models.Task(description="Test")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_roadmap_defaults():
|
|
34
|
+
roadmap = models.Roadmap()
|
|
35
|
+
assert roadmap.context == ""
|
|
36
|
+
assert roadmap.tasks == []
|
|
37
|
+
assert isinstance(roadmap.config, models.RoadmapConfig)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_roadmap_config_defaults():
|
|
41
|
+
config = models.RoadmapConfig()
|
|
42
|
+
assert config.retries == 3
|
|
43
|
+
assert config.runner in models.KNOWN_RUNNERS
|
|
44
|
+
assert config.hooks is None
|
|
45
|
+
assert config.time_limit == 60
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_detect_default_runner_picks_first_installed(monkeypatch):
|
|
49
|
+
models.detect_default_runner.cache_clear()
|
|
50
|
+
monkeypatch.setattr(
|
|
51
|
+
models.shutil,
|
|
52
|
+
"which",
|
|
53
|
+
lambda name: "/bin/x" if name == "claude" else None,
|
|
54
|
+
)
|
|
55
|
+
assert models.detect_default_runner() == "claude"
|
|
56
|
+
models.detect_default_runner.cache_clear()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_detect_default_runner_falls_back_to_agy(monkeypatch):
|
|
60
|
+
models.detect_default_runner.cache_clear()
|
|
61
|
+
monkeypatch.setattr(models.shutil, "which", lambda name: None)
|
|
62
|
+
assert models.detect_default_runner() == "agy"
|
|
63
|
+
models.detect_default_runner.cache_clear()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_detect_default_runner_prefers_agy(monkeypatch):
|
|
67
|
+
models.detect_default_runner.cache_clear()
|
|
68
|
+
monkeypatch.setattr(models.shutil, "which", lambda name: f"/bin/{name}")
|
|
69
|
+
assert models.detect_default_runner() == "agy"
|
|
70
|
+
models.detect_default_runner.cache_clear()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_roadmap_config_uses_detected_runner(monkeypatch):
|
|
74
|
+
models.detect_default_runner.cache_clear()
|
|
75
|
+
monkeypatch.setattr(
|
|
76
|
+
models.shutil,
|
|
77
|
+
"which",
|
|
78
|
+
lambda name: "/bin/x" if name == "codex" else None,
|
|
79
|
+
)
|
|
80
|
+
assert models.RoadmapConfig().runner == "codex"
|
|
81
|
+
# An explicit value always wins over detection.
|
|
82
|
+
assert models.RoadmapConfig(runner="aider").runner == "aider"
|
|
83
|
+
models.detect_default_runner.cache_clear()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_roadmap_config_custom_time_limit():
|
|
87
|
+
config = models.RoadmapConfig(time_limit=30)
|
|
88
|
+
assert config.time_limit == 30
|
|
89
|
+
|
|
90
|
+
config_disabled = models.RoadmapConfig(time_limit=0)
|
|
91
|
+
assert config_disabled.time_limit == 0
|
lemming/orchestrator.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""Orchestrator loop that executes pending tasks via runners and hooks."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import pathlib
|
|
5
|
+
import random
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from . import prompts, runner, tasks
|
|
11
|
+
from .hooks import run_hooks
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _process_exhausted_retries(
|
|
15
|
+
tasks_file: pathlib.Path,
|
|
16
|
+
task_id: str,
|
|
17
|
+
retries: int,
|
|
18
|
+
runner_name: str,
|
|
19
|
+
yolo: bool,
|
|
20
|
+
runner_args: tuple,
|
|
21
|
+
no_defaults: bool,
|
|
22
|
+
verbose: bool,
|
|
23
|
+
active_hooks: list[str],
|
|
24
|
+
working_dir: pathlib.Path | None,
|
|
25
|
+
time_limit: int,
|
|
26
|
+
) -> bool:
|
|
27
|
+
"""Handles tasks that have exhausted retries.
|
|
28
|
+
|
|
29
|
+
Returns True to abort the run loop, False to continue.
|
|
30
|
+
"""
|
|
31
|
+
# Run hooks (like roadmap revision) even on final failure to give
|
|
32
|
+
# them a chance to heal the task before we abort.
|
|
33
|
+
# Mark as in_progress so hooks can run and heartbeats work.
|
|
34
|
+
# We use update_task to set requested_status=FAILED so it shows
|
|
35
|
+
# as "Finalizing" in the UI.
|
|
36
|
+
tasks.mark_task_in_progress(tasks_file, task_id)
|
|
37
|
+
tasks.update_task(tasks_file, task_id, status=tasks.TaskStatus.FAILED)
|
|
38
|
+
|
|
39
|
+
run_hooks(
|
|
40
|
+
tasks_file,
|
|
41
|
+
task_id,
|
|
42
|
+
runner_name,
|
|
43
|
+
yolo,
|
|
44
|
+
runner_args,
|
|
45
|
+
no_defaults,
|
|
46
|
+
verbose,
|
|
47
|
+
hooks=active_hooks,
|
|
48
|
+
working_dir=working_dir,
|
|
49
|
+
final_status=tasks.TaskStatus.FAILED,
|
|
50
|
+
time_limit=time_limit,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Re-check: if a hook reset/edited/replaced the task, continue the loop
|
|
54
|
+
data = tasks.load_tasks(tasks_file)
|
|
55
|
+
healed_task = next((t for t in data.tasks if t.id == task_id), None)
|
|
56
|
+
if healed_task and healed_task.attempts >= retries:
|
|
57
|
+
click.echo(
|
|
58
|
+
f"\nTask {task_id} failed after {retries} attempts. Aborting run."
|
|
59
|
+
)
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
# Orchestrator healed it (reset attempts, deleted it, etc.) — continue
|
|
63
|
+
# the loop
|
|
64
|
+
click.echo(f"Orchestrator intervened on task {task_id}. Continuing...")
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _process_finalizing_task(
|
|
69
|
+
tasks_file: pathlib.Path,
|
|
70
|
+
task_id: str,
|
|
71
|
+
requested_status: tasks.TaskStatus,
|
|
72
|
+
runner_name: str,
|
|
73
|
+
yolo: bool,
|
|
74
|
+
runner_args: tuple,
|
|
75
|
+
no_defaults: bool,
|
|
76
|
+
verbose: bool,
|
|
77
|
+
active_hooks: list[str],
|
|
78
|
+
working_dir: pathlib.Path | None,
|
|
79
|
+
time_limit: int,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Runs hooks for a task that is in a finalizing state."""
|
|
82
|
+
if verbose:
|
|
83
|
+
click.echo(
|
|
84
|
+
f"Task {task_id} resumed in finalizing state "
|
|
85
|
+
f"({requested_status}). Skipping runner."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
run_hooks(
|
|
89
|
+
tasks_file,
|
|
90
|
+
task_id,
|
|
91
|
+
runner_name,
|
|
92
|
+
yolo,
|
|
93
|
+
runner_args,
|
|
94
|
+
no_defaults,
|
|
95
|
+
verbose,
|
|
96
|
+
hooks=active_hooks,
|
|
97
|
+
working_dir=working_dir,
|
|
98
|
+
final_status=requested_status,
|
|
99
|
+
time_limit=time_limit,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _handle_runner_exit(
|
|
104
|
+
tasks_file: pathlib.Path,
|
|
105
|
+
task_id: str,
|
|
106
|
+
returncode: int,
|
|
107
|
+
stdout: str,
|
|
108
|
+
stderr: str,
|
|
109
|
+
retries: int,
|
|
110
|
+
retry_delay: int,
|
|
111
|
+
runner_name: str,
|
|
112
|
+
yolo: bool,
|
|
113
|
+
runner_args: tuple,
|
|
114
|
+
no_defaults: bool,
|
|
115
|
+
verbose: bool,
|
|
116
|
+
active_hooks: list[str],
|
|
117
|
+
working_dir: pathlib.Path | None,
|
|
118
|
+
time_limit: int,
|
|
119
|
+
) -> bool:
|
|
120
|
+
"""Handles the aftermath of a task runner exiting.
|
|
121
|
+
|
|
122
|
+
Returns True to abort the run loop, False to continue.
|
|
123
|
+
"""
|
|
124
|
+
# Post-execution validation and heartbeat cleanup
|
|
125
|
+
# This will mark the task as COMPLETED or PENDING based on whether
|
|
126
|
+
# the agent called 'lemming complete'.
|
|
127
|
+
post_task = tasks.finish_task_attempt(tasks_file, task_id)
|
|
128
|
+
|
|
129
|
+
if not post_task:
|
|
130
|
+
click.echo("Error: Task disappeared from roadmap during execution.")
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
# Run orchestrator hooks synchronously if the task requested a status
|
|
134
|
+
# change (completion or failure). This ensures the roadmap is updated
|
|
135
|
+
# and all validation hooks complete before the next task is picked up.
|
|
136
|
+
if post_task.requested_status:
|
|
137
|
+
run_hooks(
|
|
138
|
+
tasks_file,
|
|
139
|
+
task_id,
|
|
140
|
+
runner_name,
|
|
141
|
+
yolo,
|
|
142
|
+
runner_args,
|
|
143
|
+
no_defaults,
|
|
144
|
+
verbose,
|
|
145
|
+
hooks=active_hooks,
|
|
146
|
+
working_dir=working_dir,
|
|
147
|
+
final_status=post_task.requested_status,
|
|
148
|
+
time_limit=time_limit,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if post_task.status == tasks.TaskStatus.COMPLETED:
|
|
152
|
+
if verbose:
|
|
153
|
+
click.echo("Runner successfully reported task completion.")
|
|
154
|
+
else:
|
|
155
|
+
click.echo(f"[{task_id}] Task completed successfully!")
|
|
156
|
+
else:
|
|
157
|
+
if not verbose:
|
|
158
|
+
if stdout:
|
|
159
|
+
click.echo(stdout)
|
|
160
|
+
if stderr:
|
|
161
|
+
click.echo(stderr, err=True)
|
|
162
|
+
|
|
163
|
+
if returncode == -15:
|
|
164
|
+
if verbose:
|
|
165
|
+
click.echo("Task was cancelled. Stopping orchestrator loop.")
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
if verbose:
|
|
169
|
+
click.echo(
|
|
170
|
+
"Runner finished execution but did NOT report completion. "
|
|
171
|
+
"Retrying..."
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Only sleep if the task is still pending AND it wasn't cancelled
|
|
175
|
+
# (it would have requested a status if not)
|
|
176
|
+
if (
|
|
177
|
+
post_task.status == tasks.TaskStatus.PENDING
|
|
178
|
+
and post_task.attempts < retries
|
|
179
|
+
and retry_delay > 0
|
|
180
|
+
and post_task.requested_status is None
|
|
181
|
+
):
|
|
182
|
+
if verbose:
|
|
183
|
+
click.echo(
|
|
184
|
+
f"Waiting {retry_delay} seconds before next attempt "
|
|
185
|
+
"to avoid rate limits..."
|
|
186
|
+
)
|
|
187
|
+
time.sleep(retry_delay)
|
|
188
|
+
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def run_loop(
|
|
193
|
+
tasks_file: pathlib.Path,
|
|
194
|
+
verbose: bool,
|
|
195
|
+
retry_delay: int,
|
|
196
|
+
yolo: bool,
|
|
197
|
+
no_defaults: bool,
|
|
198
|
+
runner_args: tuple,
|
|
199
|
+
working_dir: pathlib.Path | None = None,
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Starts the orchestrator loop to autonomously execute pending tasks."""
|
|
202
|
+
while True:
|
|
203
|
+
returncode = 0
|
|
204
|
+
|
|
205
|
+
# Reload configuration on each iteration to respond to changes
|
|
206
|
+
# (e.g., from Web UI)
|
|
207
|
+
data = tasks.load_tasks(tasks_file)
|
|
208
|
+
|
|
209
|
+
retries = data.config.retries
|
|
210
|
+
time_limit = data.config.time_limit
|
|
211
|
+
runner_name = data.config.runner
|
|
212
|
+
active_hooks = data.config.hooks
|
|
213
|
+
if active_hooks is None:
|
|
214
|
+
active_hooks = prompts.list_hooks(tasks_file)
|
|
215
|
+
|
|
216
|
+
current_task = tasks.get_pending_task(data)
|
|
217
|
+
|
|
218
|
+
if not current_task:
|
|
219
|
+
click.echo("All tasks completed!")
|
|
220
|
+
break
|
|
221
|
+
|
|
222
|
+
task_id = current_task.id
|
|
223
|
+
|
|
224
|
+
if current_task.attempts >= retries:
|
|
225
|
+
should_abort = _process_exhausted_retries(
|
|
226
|
+
tasks_file=tasks_file,
|
|
227
|
+
task_id=task_id,
|
|
228
|
+
retries=retries,
|
|
229
|
+
runner_name=runner_name,
|
|
230
|
+
yolo=yolo,
|
|
231
|
+
runner_args=runner_args,
|
|
232
|
+
no_defaults=no_defaults,
|
|
233
|
+
verbose=verbose,
|
|
234
|
+
active_hooks=active_hooks,
|
|
235
|
+
working_dir=working_dir,
|
|
236
|
+
time_limit=time_limit,
|
|
237
|
+
)
|
|
238
|
+
if should_abort:
|
|
239
|
+
break
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
# Add a small random jitter to avoid race conditions between
|
|
243
|
+
# multiple instances
|
|
244
|
+
time.sleep(random.uniform(0.1, 0.5))
|
|
245
|
+
|
|
246
|
+
# Try to claim the task
|
|
247
|
+
current_task = tasks.claim_task(tasks_file, task_id, pid=os.getpid())
|
|
248
|
+
if not current_task:
|
|
249
|
+
if verbose:
|
|
250
|
+
click.echo(
|
|
251
|
+
f"Task {task_id} already claimed by another instance. "
|
|
252
|
+
"Skipping."
|
|
253
|
+
)
|
|
254
|
+
continue
|
|
255
|
+
|
|
256
|
+
if verbose:
|
|
257
|
+
click.echo(
|
|
258
|
+
f"\n--- Task {task_id} "
|
|
259
|
+
f"(Attempt {current_task.attempts}/{retries}) ---"
|
|
260
|
+
)
|
|
261
|
+
click.echo(f"Working on: {current_task.description}")
|
|
262
|
+
else:
|
|
263
|
+
click.echo(
|
|
264
|
+
f"[{task_id}] Attempt {current_task.attempts}/{retries}: "
|
|
265
|
+
f"{current_task.description}"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# If the task was picked up in a finalizing state, skip the runner
|
|
269
|
+
# and go straight to hooks.
|
|
270
|
+
if current_task.requested_status:
|
|
271
|
+
_process_finalizing_task(
|
|
272
|
+
tasks_file=tasks_file,
|
|
273
|
+
task_id=task_id,
|
|
274
|
+
requested_status=current_task.requested_status,
|
|
275
|
+
runner_name=runner_name,
|
|
276
|
+
yolo=yolo,
|
|
277
|
+
runner_args=runner_args,
|
|
278
|
+
no_defaults=no_defaults,
|
|
279
|
+
verbose=verbose,
|
|
280
|
+
active_hooks=active_hooks,
|
|
281
|
+
working_dir=working_dir,
|
|
282
|
+
time_limit=time_limit,
|
|
283
|
+
)
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
prompt = prompts.prepare_prompt(
|
|
287
|
+
data, current_task, tasks_file, time_limit
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
if verbose:
|
|
291
|
+
click.secho("\n=== Runner Prompt ===", fg="blue", bold=True)
|
|
292
|
+
click.echo(prompt)
|
|
293
|
+
click.secho("====================\n", fg="blue", bold=True)
|
|
294
|
+
|
|
295
|
+
cmd = runner.build_runner_command(
|
|
296
|
+
current_task.runner or runner_name,
|
|
297
|
+
prompt,
|
|
298
|
+
yolo,
|
|
299
|
+
runner_args,
|
|
300
|
+
no_defaults,
|
|
301
|
+
verbose=verbose,
|
|
302
|
+
time_limit=time_limit,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
returncode = 0
|
|
306
|
+
stdout, stderr = "", ""
|
|
307
|
+
try:
|
|
308
|
+
returncode, stdout, stderr = runner.run_with_heartbeat(
|
|
309
|
+
cmd,
|
|
310
|
+
tasks_file,
|
|
311
|
+
task_id,
|
|
312
|
+
verbose,
|
|
313
|
+
echo_fn=lambda line: click.echo(line, nl=False),
|
|
314
|
+
header="Task Runner",
|
|
315
|
+
cwd=working_dir,
|
|
316
|
+
time_limit=time_limit,
|
|
317
|
+
)
|
|
318
|
+
if returncode == runner.RETURNCODE_TIMEOUT:
|
|
319
|
+
click.echo(
|
|
320
|
+
f"\nTask {task_id} killed: "
|
|
321
|
+
f"time limit of {time_limit}m reached."
|
|
322
|
+
)
|
|
323
|
+
elif returncode != 0:
|
|
324
|
+
click.echo(
|
|
325
|
+
f"\n{runner_name.capitalize()} execution failed "
|
|
326
|
+
f"with exit code {returncode}"
|
|
327
|
+
)
|
|
328
|
+
if returncode == 127:
|
|
329
|
+
click.echo(
|
|
330
|
+
f"\nNOTE: Command '{runner_name}' not found.\n"
|
|
331
|
+
"If you are using a shell alias, "
|
|
332
|
+
"Python subprocesses cannot see it.\n"
|
|
333
|
+
"Fixes:\n"
|
|
334
|
+
"1. Use the absolute path: `lemming config set "
|
|
335
|
+
f"runner /path/to/{runner_name}`\n"
|
|
336
|
+
"2. Create an executable wrapper script for "
|
|
337
|
+
f"'{runner_name}' in your PATH."
|
|
338
|
+
)
|
|
339
|
+
except Exception as e:
|
|
340
|
+
click.echo(
|
|
341
|
+
f"\nAn error occurred while executing {runner_name}: {e}"
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
should_abort = _handle_runner_exit(
|
|
345
|
+
tasks_file=tasks_file,
|
|
346
|
+
task_id=task_id,
|
|
347
|
+
returncode=returncode,
|
|
348
|
+
stdout=stdout,
|
|
349
|
+
stderr=stderr,
|
|
350
|
+
retries=retries,
|
|
351
|
+
retry_delay=retry_delay,
|
|
352
|
+
runner_name=runner_name,
|
|
353
|
+
yolo=yolo,
|
|
354
|
+
runner_args=runner_args,
|
|
355
|
+
no_defaults=no_defaults,
|
|
356
|
+
verbose=verbose,
|
|
357
|
+
active_hooks=active_hooks,
|
|
358
|
+
working_dir=working_dir,
|
|
359
|
+
time_limit=time_limit,
|
|
360
|
+
)
|
|
361
|
+
if should_abort:
|
|
362
|
+
break
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def format_duration(minutes: int) -> str:
|
|
366
|
+
"""Formats a duration in minutes as a human-readable string.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
minutes: Duration in minutes.
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
A human-readable duration string (e.g., '60m', '2h').
|
|
373
|
+
"""
|
|
374
|
+
if minutes <= 0:
|
|
375
|
+
return "none"
|
|
376
|
+
if minutes >= 60 and minutes % 60 == 0:
|
|
377
|
+
return f"{minutes // 60}h"
|
|
378
|
+
return f"{minutes}m"
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def parse_timeout(t_str: str) -> float:
|
|
382
|
+
"""Parses a duration string into seconds.
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
t_str: Duration string (e.g., '8h', '30m', '90s').
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
The duration in seconds as a float.
|
|
389
|
+
"""
|
|
390
|
+
t_str = t_str.strip()
|
|
391
|
+
if t_str == "0" or t_str.startswith("-"):
|
|
392
|
+
return 0.0
|
|
393
|
+
|
|
394
|
+
multiplier = 1.0
|
|
395
|
+
if t_str.endswith("h"):
|
|
396
|
+
multiplier = 3600.0
|
|
397
|
+
t_str = t_str[:-1]
|
|
398
|
+
elif t_str.endswith("m"):
|
|
399
|
+
multiplier = 60.0
|
|
400
|
+
t_str = t_str[:-1]
|
|
401
|
+
elif t_str.endswith("s"):
|
|
402
|
+
t_str = t_str[:-1]
|
|
403
|
+
|
|
404
|
+
try:
|
|
405
|
+
return float(t_str) * multiplier
|
|
406
|
+
except ValueError:
|
|
407
|
+
return 0.0
|