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.
Files changed (130) hide show
  1. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/PKG-INFO +2 -2
  2. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/pyproject.toml +2 -2
  3. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/tasks.py +11 -8
  4. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/goal.py +1 -7
  5. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/main_test.py +8 -0
  6. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/operations.py +11 -15
  7. lemming_cli-0.2.0/src/lemming/cli/operations_test.py +93 -0
  8. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/progress.py +1 -8
  9. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/tasks.py +67 -86
  10. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/tasks_test.py +65 -0
  11. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/integration_test.py +6 -6
  12. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/models.py +4 -0
  13. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/orchestrator.py +59 -6
  14. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/orchestrator_test.py +37 -0
  15. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/persistence.py +45 -4
  16. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/persistence_test.py +24 -0
  17. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/tasks/__init__.py +8 -0
  18. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/tasks/lifecycle.py +9 -29
  19. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/tasks/lifecycle_test.py +1 -0
  20. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/tasks/operations.py +51 -27
  21. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/tasks/operations_test.py +94 -1
  22. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/tasks/queries.py +17 -0
  23. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/uv.lock +5 -5
  24. lemming_cli-0.1.4/src/lemming/cli/operations_test.py +0 -44
  25. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/.github/workflows/ci.yml +0 -0
  26. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/.github/workflows/publish.yml +0 -0
  27. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/.github/workflows/screenshots.yml +0 -0
  28. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/.gitignore +0 -0
  29. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/.prettierignore +0 -0
  30. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/.prettierrc +0 -0
  31. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/Dockerfile +0 -0
  32. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/LICENSE +0 -0
  33. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/README.md +0 -0
  34. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/biome.json +0 -0
  35. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/docker-compose.yml +0 -0
  36. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/docs/EVALS.md +0 -0
  37. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/docs/HOOKS.md +0 -0
  38. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/docs/screenshots/dashboard-desktop.png +0 -0
  39. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/docs/screenshots/dashboard-mobile.png +0 -0
  40. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/docs/screenshots/task-log-desktop.png +0 -0
  41. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/docs/screenshots/task-log-mobile.png +0 -0
  42. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/package-lock.json +0 -0
  43. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/package.json +0 -0
  44. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/playwright.config.js +0 -0
  45. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/__init__.py +0 -0
  46. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/__init__.py +0 -0
  47. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/auth.py +0 -0
  48. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/auth_test.py +0 -0
  49. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/config.py +0 -0
  50. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/config_test.py +0 -0
  51. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/conftest.py +0 -0
  52. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/context.py +0 -0
  53. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/context_test.py +0 -0
  54. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/directories.py +0 -0
  55. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/directories_test.py +0 -0
  56. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/files.py +0 -0
  57. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/files_test.py +0 -0
  58. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/hooks.py +0 -0
  59. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/hooks_test.py +0 -0
  60. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/logging.py +0 -0
  61. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/logging_test.py +0 -0
  62. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/loop.py +0 -0
  63. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/loop_test.py +0 -0
  64. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/main.py +0 -0
  65. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/main_test.py +0 -0
  66. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api/tasks_test.py +0 -0
  67. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/api_test.py +0 -0
  68. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/__init__.py +0 -0
  69. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/config.py +0 -0
  70. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/config_test.py +0 -0
  71. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/goal_test.py +0 -0
  72. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/hooks.py +0 -0
  73. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/hooks_test.py +0 -0
  74. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/main.py +0 -0
  75. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/progress_test.py +0 -0
  76. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/readability_cli.py +0 -0
  77. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli/readability_cli_test.py +0 -0
  78. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/cli_test.py +0 -0
  79. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/conftest.py +0 -0
  80. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/__init__.py +0 -0
  81. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/__main__.py +0 -0
  82. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/cli.py +0 -0
  83. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/cli_test.py +0 -0
  84. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/container.py +0 -0
  85. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/container_test.py +0 -0
  86. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/fixtures.py +0 -0
  87. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/fixtures_test.py +0 -0
  88. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/harness.py +0 -0
  89. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/harness_test.py +0 -0
  90. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/readability.py +0 -0
  91. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/readability_test.py +0 -0
  92. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/roadmap.py +0 -0
  93. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/roadmap_test.py +0 -0
  94. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/scenarios.py +0 -0
  95. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/scenarios_test.py +0 -0
  96. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/suites.py +0 -0
  97. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/trial.py +0 -0
  98. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/evals/trial_test.py +0 -0
  99. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/hooks.py +0 -0
  100. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/hooks_test.py +0 -0
  101. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/main.py +0 -0
  102. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/main_test.py +0 -0
  103. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/models_test.py +0 -0
  104. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/paths.py +0 -0
  105. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/paths_test.py +0 -0
  106. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/prompts/hooks/50-readability.md +0 -0
  107. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/prompts/hooks/60-testing.md +0 -0
  108. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/prompts/hooks/90-roadmap.md +0 -0
  109. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/prompts/taskrunner.md +0 -0
  110. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/prompts.py +0 -0
  111. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/prompts_test.py +0 -0
  112. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/providers.py +0 -0
  113. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/providers_test.py +0 -0
  114. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/runner.py +0 -0
  115. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/runner_test.py +0 -0
  116. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/tasks/progress.py +0 -0
  117. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/tasks/progress_test.py +0 -0
  118. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/tasks/queries_test.py +0 -0
  119. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/web/dashboard.spec.js +0 -0
  120. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/web/dashboard.test.js +0 -0
  121. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/web/favicon.js +0 -0
  122. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/web/favicon.spec.js +0 -0
  123. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/web/files.html +0 -0
  124. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/web/files.spec.js +0 -0
  125. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/web/index.html +0 -0
  126. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/web/index.js +0 -0
  127. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/web/logs.html +0 -0
  128. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/web/logs.test.js +0 -0
  129. {lemming_cli-0.1.4 → lemming_cli-0.2.0}/src/lemming/web/mancha.js +0 -0
  130. {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.1.4
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.4.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.1.4"
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.4.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
- new_task = tasks.add_task(
38
- tasks_file,
39
- task.description,
40
- task.runner,
41
- index=task.index,
42
- parent=task.parent,
43
- parent_tasks_file=task.parent_tasks_file,
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
- tasks.acquire_loop_lock(tasks_file)
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="Index to insert the task at (defaults to -1, the end).",
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
- new_task = tasks.add_task(
78
- tasks_file,
79
- description,
80
- runner_name,
81
- index=index,
82
- parent=parent,
83
- parent_tasks_file=parent_tasks_file,
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("--index", type=int, help="New index in the task queue.")
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
- removed = tasks.delete_tasks(
209
- tasks_file,
210
- task_id=task_id,
211
- all_tasks=delete_all,
212
- completed_only=completed,
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
- click.echo(f"Task {target_task.id} marked as completed.")
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 at least one recorded progress entry).
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 for an in-progress task and marks it as cancelled.
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 all history (attempts, progress, and logs) for a specific task.
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 test_orchestrator_retries_on_runner_crash(self):
189
- """Verify the orchestrator retries if the runner exits non-zero."""
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
- # 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)
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