lemming-cli 0.1.4__tar.gz → 0.2.0__tar.gz
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_cli-0.1.4 → lemming_cli-0.2.0}/PKG-INFO +2 -2
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/pyproject.toml +2 -2
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/tasks.py +11 -8
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/goal.py +1 -7
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/main_test.py +8 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/operations.py +11 -15
- lemming_cli-0.2.0/src/lemming/cli/operations_test.py +93 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/progress.py +1 -8
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/tasks.py +67 -86
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/tasks_test.py +65 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/integration_test.py +6 -6
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/models.py +4 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/orchestrator.py +59 -6
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/orchestrator_test.py +37 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/persistence.py +45 -4
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/persistence_test.py +24 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/tasks/__init__.py +8 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/tasks/lifecycle.py +9 -29
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/tasks/lifecycle_test.py +1 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/tasks/operations.py +51 -27
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/tasks/operations_test.py +94 -1
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/tasks/queries.py +17 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/uv.lock +5 -5
- lemming_cli-0.1.4/src/lemming/cli/operations_test.py +0 -44
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/.github/workflows/ci.yml +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/.github/workflows/publish.yml +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/.github/workflows/screenshots.yml +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/.gitignore +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/.prettierignore +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/.prettierrc +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/Dockerfile +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/LICENSE +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/README.md +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/biome.json +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/docker-compose.yml +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/docs/EVALS.md +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/docs/HOOKS.md +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/docs/screenshots/dashboard-desktop.png +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/docs/screenshots/dashboard-mobile.png +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/docs/screenshots/task-log-desktop.png +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/docs/screenshots/task-log-mobile.png +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/package-lock.json +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/package.json +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/playwright.config.js +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/__init__.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/__init__.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/auth.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/auth_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/config.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/config_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/conftest.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/context.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/context_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/directories.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/directories_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/files.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/files_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/hooks.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/hooks_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/logging.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/logging_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/loop.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/loop_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/main.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/main_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/tasks_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/__init__.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/config.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/config_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/goal_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/hooks.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/hooks_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/main.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/progress_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/readability_cli.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/readability_cli_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/conftest.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/__init__.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/__main__.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/cli.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/cli_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/container.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/container_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/fixtures.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/fixtures_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/harness.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/harness_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/readability.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/readability_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/roadmap.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/roadmap_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/scenarios.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/scenarios_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/suites.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/trial.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/trial_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/hooks.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/hooks_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/main.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/main_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/models_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/paths.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/paths_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/prompts/hooks/50-readability.md +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/prompts/hooks/60-testing.md +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/prompts/hooks/90-roadmap.md +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/prompts/taskrunner.md +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/prompts.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/prompts_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/providers.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/providers_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/runner.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/runner_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/tasks/progress.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/tasks/progress_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/tasks/queries_test.py +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/web/dashboard.spec.js +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/web/dashboard.test.js +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/web/favicon.js +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/web/favicon.spec.js +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/web/files.html +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/web/files.spec.js +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/web/index.html +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/web/index.js +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/web/logs.html +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/web/logs.test.js +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/web/mancha.js +0 -0
- {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/web/screenshots.spec.js +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lemming-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: An autonomous, iterative task runner for AI coding agents.
|
|
5
5
|
Project-URL: Homepage, https://github.com/owahltinez/lemming
|
|
6
6
|
Project-URL: Repository, https://github.com/owahltinez/lemming
|
|
@@ -15,7 +15,7 @@ Requires-Dist: fastapi>=0.124.4
|
|
|
15
15
|
Requires-Dist: markdownify>=0.14.1
|
|
16
16
|
Requires-Dist: pydantic>=2.12.5
|
|
17
17
|
Requires-Dist: pyyaml>=6.0.3
|
|
18
|
-
Requires-Dist: readability-cli>=0.
|
|
18
|
+
Requires-Dist: readability-cli>=0.5.0
|
|
19
19
|
Requires-Dist: requests>=2.32.3
|
|
20
20
|
Requires-Dist: uvicorn>=0.33.0
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "lemming-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "An autonomous, iterative task runner for AI coding agents."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [
|
|
@@ -20,7 +20,7 @@ dependencies = [
|
|
|
20
20
|
"markdownify>=0.14.1",
|
|
21
21
|
"pydantic>=2.12.5",
|
|
22
22
|
"pyyaml>=6.0.3",
|
|
23
|
-
"readability-cli>=0.
|
|
23
|
+
"readability-cli>=0.5.0",
|
|
24
24
|
"requests>=2.32.3",
|
|
25
25
|
"uvicorn>=0.33.0",
|
|
26
26
|
]
|
|
@@ -34,14 +34,17 @@ def add_task(
|
|
|
34
34
|
):
|
|
35
35
|
"""Add a new task and start the orchestrator loop if needed."""
|
|
36
36
|
tasks_file = context.resolve_tasks_file(request.app.state, project)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
37
|
+
try:
|
|
38
|
+
new_task = tasks.add_task(
|
|
39
|
+
tasks_file,
|
|
40
|
+
task.description,
|
|
41
|
+
task.runner,
|
|
42
|
+
index=task.index,
|
|
43
|
+
parent=task.parent,
|
|
44
|
+
parent_tasks_file=task.parent_tasks_file,
|
|
45
|
+
)
|
|
46
|
+
except ValueError as e:
|
|
47
|
+
raise fastapi.HTTPException(400, str(e))
|
|
45
48
|
loop.start_loop_if_needed(
|
|
46
49
|
request.app.state,
|
|
47
50
|
tasks_file,
|
|
@@ -18,13 +18,7 @@ from .main import cli
|
|
|
18
18
|
)
|
|
19
19
|
@click.pass_context
|
|
20
20
|
def goal(ctx: click.Context, goal_text: str | None, file: pathlib.Path | None):
|
|
21
|
-
"""Sets or displays the long-term goal shared across all tasks.
|
|
22
|
-
|
|
23
|
-
Args:
|
|
24
|
-
ctx: The click context holding shared CLI state.
|
|
25
|
-
goal_text: The goal string to set (optional).
|
|
26
|
-
file: A file path to read the goal from (optional).
|
|
27
|
-
"""
|
|
21
|
+
"""Sets or displays the long-term goal shared across all tasks."""
|
|
28
22
|
tasks_file = ctx.obj["TASKS_FILE"]
|
|
29
23
|
|
|
30
24
|
if file:
|
|
@@ -16,6 +16,14 @@ class TestCLIMain(unittest.TestCase):
|
|
|
16
16
|
"Lemming: An autonomous, iterative task runner", result.output
|
|
17
17
|
)
|
|
18
18
|
|
|
19
|
+
def test_command_help_hides_internal_docstrings(self):
|
|
20
|
+
for command_name in cli.commands:
|
|
21
|
+
with self.subTest(command=command_name):
|
|
22
|
+
result = self.cli_runner.invoke(cli, [command_name, "--help"])
|
|
23
|
+
self.assertEqual(result.exit_code, 0)
|
|
24
|
+
self.assertNotIn("Args:", result.output)
|
|
25
|
+
self.assertNotIn("ctx: The click context", result.output)
|
|
26
|
+
|
|
19
27
|
|
|
20
28
|
if __name__ == "__main__":
|
|
21
29
|
unittest.main()
|
|
@@ -21,9 +21,7 @@ from .main import cli
|
|
|
21
21
|
@click.option(
|
|
22
22
|
"--retry-delay",
|
|
23
23
|
default=10,
|
|
24
|
-
help=
|
|
25
|
-
"Seconds to wait before retrying a failed task (to handle rate limits)."
|
|
26
|
-
),
|
|
24
|
+
help="Seconds to wait before retrying an incomplete task.",
|
|
27
25
|
)
|
|
28
26
|
@click.option(
|
|
29
27
|
"--yolo/--no-yolo",
|
|
@@ -50,16 +48,7 @@ def run(
|
|
|
50
48
|
no_defaults: bool,
|
|
51
49
|
runner_args: tuple,
|
|
52
50
|
) -> None:
|
|
53
|
-
"""Starts the orchestrator loop to autonomously execute pending tasks.
|
|
54
|
-
|
|
55
|
-
Args:
|
|
56
|
-
ctx: The click context.
|
|
57
|
-
retry_delay: Delay between retries.
|
|
58
|
-
yolo: If True, skip runner confirmations.
|
|
59
|
-
env: Environment variables to inject.
|
|
60
|
-
no_defaults: Skip default flag injection.
|
|
61
|
-
runner_args: Raw arguments passed directly to the runner.
|
|
62
|
-
"""
|
|
51
|
+
"""Starts the orchestrator loop to autonomously execute pending tasks."""
|
|
63
52
|
tasks_file = ctx.obj["TASKS_FILE"]
|
|
64
53
|
verbose = ctx.obj["VERBOSE"]
|
|
65
54
|
|
|
@@ -78,9 +67,14 @@ def run(
|
|
|
78
67
|
if env_overrides:
|
|
79
68
|
os.environ.update(env_overrides)
|
|
80
69
|
|
|
81
|
-
|
|
70
|
+
completed = False
|
|
71
|
+
try:
|
|
72
|
+
tasks.acquire_loop_lock(tasks_file)
|
|
73
|
+
except tasks.LoopAlreadyRunningError as e:
|
|
74
|
+
click.echo(f"Error: {e}")
|
|
75
|
+
ctx.exit(1)
|
|
82
76
|
try:
|
|
83
|
-
run_loop(
|
|
77
|
+
completed = run_loop(
|
|
84
78
|
tasks_file,
|
|
85
79
|
verbose,
|
|
86
80
|
retry_delay,
|
|
@@ -91,6 +85,8 @@ def run(
|
|
|
91
85
|
)
|
|
92
86
|
finally:
|
|
93
87
|
tasks.release_loop_lock(tasks_file)
|
|
88
|
+
if not completed:
|
|
89
|
+
ctx.exit(1)
|
|
94
90
|
|
|
95
91
|
|
|
96
92
|
@cli.command(short_help="Launch the web interface")
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
import shutil
|
|
3
|
+
import tempfile
|
|
4
|
+
import time
|
|
5
|
+
import unittest
|
|
6
|
+
from unittest import mock
|
|
7
|
+
|
|
8
|
+
import click.testing
|
|
9
|
+
|
|
10
|
+
from lemming import cli, tasks
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestCLIOperations(unittest.TestCase):
|
|
14
|
+
def setUp(self):
|
|
15
|
+
self.cli_runner = click.testing.CliRunner()
|
|
16
|
+
self.test_dir = tempfile.mkdtemp()
|
|
17
|
+
self.test_tasks_file = pathlib.Path(self.test_dir) / "tasks_test.yml"
|
|
18
|
+
self.base_args = [
|
|
19
|
+
"--verbose",
|
|
20
|
+
"--tasks-file",
|
|
21
|
+
str(self.test_tasks_file),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
# Scaffold a valid file
|
|
25
|
+
data = tasks.Roadmap(
|
|
26
|
+
goal="Initial goal",
|
|
27
|
+
tasks=[],
|
|
28
|
+
)
|
|
29
|
+
tasks.save_tasks(self.test_tasks_file, data)
|
|
30
|
+
|
|
31
|
+
def tearDown(self):
|
|
32
|
+
shutil.rmtree(self.test_dir)
|
|
33
|
+
|
|
34
|
+
def test_run_help(self):
|
|
35
|
+
result = self.cli_runner.invoke(cli.cli, ["run", "--help"])
|
|
36
|
+
self.assertEqual(result.exit_code, 0)
|
|
37
|
+
self.assertIn("Starts the orchestrator loop", result.output)
|
|
38
|
+
|
|
39
|
+
def test_run_empty_queue_reports_completion(self):
|
|
40
|
+
result = self.cli_runner.invoke(cli.cli, self.base_args + ["run"])
|
|
41
|
+
|
|
42
|
+
self.assertEqual(result.exit_code, 0)
|
|
43
|
+
self.assertIn("All tasks completed!", result.output)
|
|
44
|
+
|
|
45
|
+
def test_run_blocked_queue_exits_nonzero(self):
|
|
46
|
+
tasks.save_tasks(
|
|
47
|
+
self.test_tasks_file,
|
|
48
|
+
tasks.Roadmap(
|
|
49
|
+
tasks=[
|
|
50
|
+
tasks.Task(
|
|
51
|
+
id="active123",
|
|
52
|
+
description="Active task",
|
|
53
|
+
status=tasks.TaskStatus.IN_PROGRESS,
|
|
54
|
+
pid=1234,
|
|
55
|
+
last_heartbeat=time.time(),
|
|
56
|
+
),
|
|
57
|
+
tasks.Task(
|
|
58
|
+
id="pending456",
|
|
59
|
+
description="Pending task",
|
|
60
|
+
status=tasks.TaskStatus.PENDING,
|
|
61
|
+
),
|
|
62
|
+
]
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
with mock.patch(
|
|
67
|
+
"lemming.tasks.lifecycle.is_pid_alive", return_value=True
|
|
68
|
+
):
|
|
69
|
+
result = self.cli_runner.invoke(cli.cli, self.base_args + ["run"])
|
|
70
|
+
|
|
71
|
+
self.assertEqual(result.exit_code, 1)
|
|
72
|
+
self.assertIn("Queue blocked by active task active123", result.output)
|
|
73
|
+
self.assertIn("1 pending task remains", result.output)
|
|
74
|
+
self.assertNotIn("All tasks completed!", result.output)
|
|
75
|
+
|
|
76
|
+
def test_run_rejects_live_loop_owner(self):
|
|
77
|
+
tasks.acquire_loop_lock(self.test_tasks_file)
|
|
78
|
+
try:
|
|
79
|
+
result = self.cli_runner.invoke(cli.cli, self.base_args + ["run"])
|
|
80
|
+
finally:
|
|
81
|
+
tasks.release_loop_lock(self.test_tasks_file)
|
|
82
|
+
|
|
83
|
+
self.assertEqual(result.exit_code, 1)
|
|
84
|
+
self.assertIn("Another loop is already running", result.output)
|
|
85
|
+
|
|
86
|
+
def test_serve_help(self):
|
|
87
|
+
result = self.cli_runner.invoke(cli.cli, ["serve", "--help"])
|
|
88
|
+
self.assertEqual(result.exit_code, 0)
|
|
89
|
+
self.assertIn("Launches the local web dashboard", result.output)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
unittest.main()
|
|
@@ -24,14 +24,7 @@ def progress(
|
|
|
24
24
|
text: typing.Optional[str],
|
|
25
25
|
file: typing.Optional[typing.TextIO],
|
|
26
26
|
):
|
|
27
|
-
"""Records a progress entry or finding for a specific task.
|
|
28
|
-
|
|
29
|
-
Args:
|
|
30
|
-
ctx: The click context holding shared CLI state.
|
|
31
|
-
task_id: The ID of the task.
|
|
32
|
-
text: The progress to record (optional if --file is used).
|
|
33
|
-
file: An optional file to read the progress from.
|
|
34
|
-
"""
|
|
27
|
+
"""Records a progress entry or finding for a specific task."""
|
|
35
28
|
tasks_file = ctx.obj["TASKS_FILE"]
|
|
36
29
|
|
|
37
30
|
if file:
|
|
@@ -20,7 +20,7 @@ from .main import cli
|
|
|
20
20
|
@click.option(
|
|
21
21
|
"--index",
|
|
22
22
|
default=-1,
|
|
23
|
-
help="
|
|
23
|
+
help="Zero-based displayed queue position (-1 appends).",
|
|
24
24
|
)
|
|
25
25
|
@click.option(
|
|
26
26
|
"--runner",
|
|
@@ -47,18 +47,7 @@ def add(
|
|
|
47
47
|
parent: str | None,
|
|
48
48
|
parent_tasks_file: str | None,
|
|
49
49
|
):
|
|
50
|
-
"""Adds a new task to the roadmap queue.
|
|
51
|
-
|
|
52
|
-
Args:
|
|
53
|
-
ctx: The click context.
|
|
54
|
-
description: A text description of the task to perform (optional if
|
|
55
|
-
--file is used).
|
|
56
|
-
file: An optional file to read the description from.
|
|
57
|
-
index: The position in the roadmap to insert the task.
|
|
58
|
-
runner_name: An optional custom runner to use for this specific task.
|
|
59
|
-
parent: Optional parent task ID.
|
|
60
|
-
parent_tasks_file: Optional parent tasks file path.
|
|
61
|
-
"""
|
|
50
|
+
"""Adds a new task to the roadmap queue."""
|
|
62
51
|
tasks_file = ctx.obj["TASKS_FILE"]
|
|
63
52
|
verbose = ctx.obj["VERBOSE"]
|
|
64
53
|
|
|
@@ -74,14 +63,18 @@ def add(
|
|
|
74
63
|
click.echo("Error: Must provide either description or --file.")
|
|
75
64
|
ctx.exit(1)
|
|
76
65
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
66
|
+
try:
|
|
67
|
+
new_task = tasks.add_task(
|
|
68
|
+
tasks_file,
|
|
69
|
+
description,
|
|
70
|
+
runner_name,
|
|
71
|
+
index=index,
|
|
72
|
+
parent=parent,
|
|
73
|
+
parent_tasks_file=parent_tasks_file,
|
|
74
|
+
)
|
|
75
|
+
except ValueError as e:
|
|
76
|
+
click.echo(f"Error: {e}")
|
|
77
|
+
ctx.exit(1)
|
|
85
78
|
task_id = new_task.id
|
|
86
79
|
|
|
87
80
|
if verbose:
|
|
@@ -100,7 +93,11 @@ def add(
|
|
|
100
93
|
help="Read new description from a file (or - for stdin).",
|
|
101
94
|
)
|
|
102
95
|
@click.option("--runner", "runner_name", help="New custom runner for the task.")
|
|
103
|
-
@click.option(
|
|
96
|
+
@click.option(
|
|
97
|
+
"--index",
|
|
98
|
+
type=int,
|
|
99
|
+
help="New zero-based position in the displayed task queue.",
|
|
100
|
+
)
|
|
104
101
|
@click.option(
|
|
105
102
|
"--parent",
|
|
106
103
|
help="New parent task ID (use empty string to remove).",
|
|
@@ -120,18 +117,7 @@ def edit(
|
|
|
120
117
|
parent: str | None,
|
|
121
118
|
parent_tasks_file: str | None,
|
|
122
119
|
):
|
|
123
|
-
"""Edits an existing task's description, runner, position, or parent.
|
|
124
|
-
|
|
125
|
-
Args:
|
|
126
|
-
ctx: The click context.
|
|
127
|
-
task_id: The ID of the task to update.
|
|
128
|
-
description: The new description (optional).
|
|
129
|
-
file: An optional file to read the new description from.
|
|
130
|
-
runner_name: The new preferred runner (optional).
|
|
131
|
-
index: The new position in the roadmap (optional).
|
|
132
|
-
parent: The new parent task ID (optional).
|
|
133
|
-
parent_tasks_file: The new parent tasks file path (optional).
|
|
134
|
-
"""
|
|
120
|
+
"""Edits an existing task's description, runner, position, or parent."""
|
|
135
121
|
if file:
|
|
136
122
|
if description:
|
|
137
123
|
click.echo("Error: Cannot provide both description and --file.")
|
|
@@ -184,14 +170,7 @@ def edit(
|
|
|
184
170
|
def delete_task(
|
|
185
171
|
ctx: click.Context, task_id: str | None, delete_all: bool, completed: bool
|
|
186
172
|
):
|
|
187
|
-
"""Deletes one or more tasks from the roadmap.
|
|
188
|
-
|
|
189
|
-
Args:
|
|
190
|
-
ctx: The click context.
|
|
191
|
-
task_id: The ID of the specific task to delete.
|
|
192
|
-
delete_all: If set, clears the entire roadmap and long-term goal.
|
|
193
|
-
completed: If set, deletes all tasks marked as 'completed'.
|
|
194
|
-
"""
|
|
173
|
+
"""Deletes one or more tasks from the roadmap."""
|
|
195
174
|
tasks_file = ctx.obj["TASKS_FILE"]
|
|
196
175
|
|
|
197
176
|
# Validate argument combinations
|
|
@@ -205,12 +184,16 @@ def delete_task(
|
|
|
205
184
|
click.echo("Error: Provide a task ID, or use --all or --completed.")
|
|
206
185
|
ctx.exit(1)
|
|
207
186
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
187
|
+
try:
|
|
188
|
+
removed = tasks.delete_tasks(
|
|
189
|
+
tasks_file,
|
|
190
|
+
task_id=task_id,
|
|
191
|
+
all_tasks=delete_all,
|
|
192
|
+
completed_only=completed,
|
|
193
|
+
)
|
|
194
|
+
except ValueError as e:
|
|
195
|
+
click.echo(f"Error: {e}")
|
|
196
|
+
ctx.exit(1)
|
|
214
197
|
|
|
215
198
|
if delete_all:
|
|
216
199
|
click.echo(
|
|
@@ -229,12 +212,7 @@ def delete_task(
|
|
|
229
212
|
@click.argument("task_id", required=False)
|
|
230
213
|
@click.pass_context
|
|
231
214
|
def status(ctx: click.Context, task_id: str | None):
|
|
232
|
-
"""Displays the roadmap status or details for a specific task.
|
|
233
|
-
|
|
234
|
-
Args:
|
|
235
|
-
ctx: The click context.
|
|
236
|
-
task_id: Optional ID of the task to inspect in detail.
|
|
237
|
-
"""
|
|
215
|
+
"""Displays the roadmap status or details for a specific task."""
|
|
238
216
|
tasks_file = ctx.obj["TASKS_FILE"]
|
|
239
217
|
verbose = ctx.obj["VERBOSE"]
|
|
240
218
|
project_data = tasks.get_project_data(tasks_file)
|
|
@@ -422,24 +400,47 @@ def logs(ctx: click.Context, task_id: str | None):
|
|
|
422
400
|
|
|
423
401
|
@cli.command(short_help="<taskid> Mark a task as completed")
|
|
424
402
|
@click.argument("task_id")
|
|
403
|
+
@click.option(
|
|
404
|
+
"--force",
|
|
405
|
+
is_flag=True,
|
|
406
|
+
help="Finalize an in-progress task immediately, without running hooks.",
|
|
407
|
+
)
|
|
425
408
|
@click.pass_context
|
|
426
|
-
def complete(ctx: click.Context, task_id: str):
|
|
427
|
-
"""Marks a task as completed (requires at least one progress entry).
|
|
428
|
-
|
|
429
|
-
Args:
|
|
430
|
-
ctx: The click context.
|
|
431
|
-
task_id: The ID of the task to mark as completed.
|
|
432
|
-
"""
|
|
409
|
+
def complete(ctx: click.Context, task_id: str, force: bool):
|
|
410
|
+
"""Marks a task as completed (requires at least one progress entry)."""
|
|
433
411
|
tasks_file = ctx.obj["TASKS_FILE"]
|
|
434
412
|
|
|
435
413
|
try:
|
|
414
|
+
data = tasks.load_tasks(tasks_file)
|
|
415
|
+
current_task = tasks.resolve_task(data.tasks, task_id)
|
|
416
|
+
task_is_active = tasks.is_task_active(current_task, time.time())
|
|
417
|
+
if (
|
|
418
|
+
current_task.status == tasks.TaskStatus.IN_PROGRESS
|
|
419
|
+
and current_task.progress
|
|
420
|
+
and not force
|
|
421
|
+
and not task_is_active
|
|
422
|
+
and not tasks.is_loop_running(tasks_file)
|
|
423
|
+
):
|
|
424
|
+
raise ValueError(
|
|
425
|
+
f"Task {current_task.id} is in progress, but no active runner "
|
|
426
|
+
"or loop can finalize it. Use --force to complete it without "
|
|
427
|
+
"running hooks, or reset it to retry."
|
|
428
|
+
)
|
|
429
|
+
|
|
436
430
|
target_task = tasks.update_task(
|
|
437
431
|
tasks_file,
|
|
438
432
|
task_id,
|
|
439
433
|
status=tasks.TaskStatus.COMPLETED,
|
|
440
434
|
require_progress=True,
|
|
435
|
+
force=force,
|
|
441
436
|
)
|
|
442
|
-
|
|
437
|
+
if target_task.requested_status == tasks.TaskStatus.COMPLETED:
|
|
438
|
+
click.echo(
|
|
439
|
+
f"Task {target_task.id} completion requested; "
|
|
440
|
+
"finalization hooks are pending."
|
|
441
|
+
)
|
|
442
|
+
else:
|
|
443
|
+
click.echo(f"Task {target_task.id} marked as completed.")
|
|
443
444
|
except ValueError as e:
|
|
444
445
|
click.echo(f"Error: {e}")
|
|
445
446
|
ctx.exit(1)
|
|
@@ -449,12 +450,7 @@ def complete(ctx: click.Context, task_id: str):
|
|
|
449
450
|
@click.argument("task_id")
|
|
450
451
|
@click.pass_context
|
|
451
452
|
def uncomplete(ctx: click.Context, task_id: str):
|
|
452
|
-
"""Unmarks a completed task, moving it back to 'pending' status.
|
|
453
|
-
|
|
454
|
-
Args:
|
|
455
|
-
ctx: The click context.
|
|
456
|
-
task_id: The ID of the task to uncomplete.
|
|
457
|
-
"""
|
|
453
|
+
"""Unmarks a completed task, moving it back to 'pending' status."""
|
|
458
454
|
tasks_file = ctx.obj["TASKS_FILE"]
|
|
459
455
|
try:
|
|
460
456
|
target_task = tasks.update_task(
|
|
@@ -470,12 +466,7 @@ def uncomplete(ctx: click.Context, task_id: str):
|
|
|
470
466
|
@click.argument("task_id")
|
|
471
467
|
@click.pass_context
|
|
472
468
|
def fail(ctx: click.Context, task_id: str):
|
|
473
|
-
"""Marks a task as failed (requires
|
|
474
|
-
|
|
475
|
-
Args:
|
|
476
|
-
ctx: The click context.
|
|
477
|
-
task_id: The ID of the task to mark as failed.
|
|
478
|
-
"""
|
|
469
|
+
"""Marks a task as failed (requires recorded progress)."""
|
|
479
470
|
tasks_file = ctx.obj["TASKS_FILE"]
|
|
480
471
|
try:
|
|
481
472
|
target_task = tasks.update_task(
|
|
@@ -494,12 +485,7 @@ def fail(ctx: click.Context, task_id: str):
|
|
|
494
485
|
@click.argument("task_id")
|
|
495
486
|
@click.pass_context
|
|
496
487
|
def cancel(ctx: click.Context, task_id: str):
|
|
497
|
-
"""Kills the runner
|
|
498
|
-
|
|
499
|
-
Args:
|
|
500
|
-
ctx: The click context.
|
|
501
|
-
task_id: The ID of the task to cancel.
|
|
502
|
-
"""
|
|
488
|
+
"""Kills the runner and cancels an in-progress task."""
|
|
503
489
|
tasks_file = ctx.obj["TASKS_FILE"]
|
|
504
490
|
if tasks.cancel_task(tasks_file, task_id):
|
|
505
491
|
click.echo(f"Task {task_id} cancelled.")
|
|
@@ -512,12 +498,7 @@ def cancel(ctx: click.Context, task_id: str):
|
|
|
512
498
|
@click.argument("task_id")
|
|
513
499
|
@click.pass_context
|
|
514
500
|
def reset(ctx: click.Context, task_id: str):
|
|
515
|
-
"""Clears
|
|
516
|
-
|
|
517
|
-
Args:
|
|
518
|
-
ctx: The click context.
|
|
519
|
-
task_id: The ID of the task to reset.
|
|
520
|
-
"""
|
|
501
|
+
"""Clears a task's attempts, progress, and logs."""
|
|
521
502
|
tasks_file = ctx.obj["TASKS_FILE"]
|
|
522
503
|
try:
|
|
523
504
|
target_task = tasks.reset_task(tasks_file, task_id)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import pathlib
|
|
2
2
|
import shutil
|
|
3
3
|
import tempfile
|
|
4
|
+
import time
|
|
4
5
|
import unittest
|
|
5
6
|
from unittest import mock
|
|
6
7
|
|
|
@@ -115,6 +116,70 @@ class TestCLITasks(unittest.TestCase):
|
|
|
115
116
|
data = tasks.load_tasks(self.test_tasks_file)
|
|
116
117
|
self.assertEqual(data.tasks[0].status, tasks.TaskStatus.COMPLETED)
|
|
117
118
|
|
|
119
|
+
def test_active_task_completion_runs_hooks(self):
|
|
120
|
+
with tasks.lock_tasks(self.test_tasks_file):
|
|
121
|
+
data = tasks.load_tasks(self.test_tasks_file)
|
|
122
|
+
data.tasks[0].status = tasks.TaskStatus.IN_PROGRESS
|
|
123
|
+
data.tasks[0].progress = ["Done"]
|
|
124
|
+
data.tasks[0].pid = 1234
|
|
125
|
+
data.tasks[0].last_heartbeat = time.time()
|
|
126
|
+
tasks.save_tasks(self.test_tasks_file, data)
|
|
127
|
+
|
|
128
|
+
with mock.patch(
|
|
129
|
+
"lemming.tasks.lifecycle.is_pid_alive", return_value=True
|
|
130
|
+
):
|
|
131
|
+
result = self.cli_runner.invoke(
|
|
132
|
+
cli.cli, self.base_args + ["complete", "12345678"]
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
self.assertEqual(result.exit_code, 0)
|
|
136
|
+
self.assertIn("completion requested", result.output)
|
|
137
|
+
data = tasks.load_tasks(self.test_tasks_file)
|
|
138
|
+
self.assertEqual(data.tasks[0].status, tasks.TaskStatus.IN_PROGRESS)
|
|
139
|
+
self.assertEqual(
|
|
140
|
+
data.tasks[0].requested_status, tasks.TaskStatus.COMPLETED
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def test_stale_task_completion_requires_force(self):
|
|
144
|
+
with tasks.lock_tasks(self.test_tasks_file):
|
|
145
|
+
data = tasks.load_tasks(self.test_tasks_file)
|
|
146
|
+
data.tasks[0].status = tasks.TaskStatus.IN_PROGRESS
|
|
147
|
+
data.tasks[0].progress = ["Done"]
|
|
148
|
+
data.tasks[0].pid = 999999
|
|
149
|
+
data.tasks[0].last_heartbeat = 0
|
|
150
|
+
tasks.save_tasks(self.test_tasks_file, data)
|
|
151
|
+
|
|
152
|
+
result = self.cli_runner.invoke(
|
|
153
|
+
cli.cli, self.base_args + ["complete", "12345678"]
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
self.assertNotEqual(result.exit_code, 0)
|
|
157
|
+
self.assertIn("Use --force", result.output)
|
|
158
|
+
data = tasks.load_tasks(self.test_tasks_file)
|
|
159
|
+
self.assertEqual(data.tasks[0].status, tasks.TaskStatus.IN_PROGRESS)
|
|
160
|
+
self.assertIsNone(data.tasks[0].requested_status)
|
|
161
|
+
|
|
162
|
+
def test_force_completes_stale_task(self):
|
|
163
|
+
with tasks.lock_tasks(self.test_tasks_file):
|
|
164
|
+
data = tasks.load_tasks(self.test_tasks_file)
|
|
165
|
+
data.tasks[0].status = tasks.TaskStatus.IN_PROGRESS
|
|
166
|
+
data.tasks[0].progress = ["Done"]
|
|
167
|
+
data.tasks[0].pid = 999999
|
|
168
|
+
data.tasks[0].last_heartbeat = 0
|
|
169
|
+
tasks.save_tasks(self.test_tasks_file, data)
|
|
170
|
+
|
|
171
|
+
result = self.cli_runner.invoke(
|
|
172
|
+
cli.cli, self.base_args + ["complete", "--force", "12345678"]
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
self.assertEqual(result.exit_code, 0)
|
|
176
|
+
self.assertIn("marked as completed", result.output)
|
|
177
|
+
data = tasks.load_tasks(self.test_tasks_file)
|
|
178
|
+
self.assertEqual(data.tasks[0].status, tasks.TaskStatus.COMPLETED)
|
|
179
|
+
self.assertIsNone(data.tasks[0].requested_status)
|
|
180
|
+
self.assertIsNone(data.tasks[0].pid)
|
|
181
|
+
self.assertIsNone(data.tasks[0].last_heartbeat)
|
|
182
|
+
|
|
118
183
|
def test_task_uncomplete(self):
|
|
119
184
|
# First complete it
|
|
120
185
|
self.cli_runner.invoke(
|
|
@@ -185,15 +185,15 @@ class TestIntegration(unittest.TestCase):
|
|
|
185
185
|
"marked COMPLETED",
|
|
186
186
|
)
|
|
187
187
|
|
|
188
|
-
def
|
|
189
|
-
"""Verify
|
|
188
|
+
def test_orchestrator_stops_on_runner_crash(self):
|
|
189
|
+
"""Verify a non-zero runner exit stops without consuming an attempt."""
|
|
190
190
|
with unittest.mock.patch("time.sleep", return_value=None):
|
|
191
191
|
data = tasks.load_tasks(self.test_tasks_file)
|
|
192
192
|
data.config.retries = 3
|
|
193
193
|
data.config.runner = "false" # 'false' command exits with 1
|
|
194
194
|
tasks.save_tasks(self.test_tasks_file, data)
|
|
195
195
|
|
|
196
|
-
orchestrator.run_loop(
|
|
196
|
+
completed = orchestrator.run_loop(
|
|
197
197
|
self.test_tasks_file,
|
|
198
198
|
verbose=False,
|
|
199
199
|
retry_delay=0,
|
|
@@ -203,9 +203,9 @@ class TestIntegration(unittest.TestCase):
|
|
|
203
203
|
)
|
|
204
204
|
|
|
205
205
|
data = tasks.load_tasks(self.test_tasks_file)
|
|
206
|
-
|
|
207
|
-
self.assertEqual(data.tasks[0].attempts,
|
|
208
|
-
self.assertEqual(data.tasks[0].status, models.TaskStatus.
|
|
206
|
+
self.assertFalse(completed)
|
|
207
|
+
self.assertEqual(data.tasks[0].attempts, 0)
|
|
208
|
+
self.assertEqual(data.tasks[0].status, models.TaskStatus.PENDING)
|
|
209
209
|
|
|
210
210
|
def test_orchestrator_stops_on_cancellation(self):
|
|
211
211
|
"""Verify the orchestrator stops if the runner exits with SIGTERM."""
|
|
@@ -28,6 +28,10 @@ class TaskNotFoundError(ValueError):
|
|
|
28
28
|
"""Raised when a task ID cannot be found in the roadmap."""
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
class AmbiguousTaskIdError(ValueError):
|
|
32
|
+
"""Raised when a task ID prefix matches more than one task."""
|
|
33
|
+
|
|
34
|
+
|
|
31
35
|
class TaskStatus(enum.StrEnum):
|
|
32
36
|
"""Enumeration of possible task statuses."""
|
|
33
37
|
|