lemming-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. lemming/__init__.py +1 -0
  2. lemming/api/__init__.py +5 -0
  3. lemming/api/auth.py +34 -0
  4. lemming/api/auth_test.py +40 -0
  5. lemming/api/config.py +85 -0
  6. lemming/api/config_test.py +84 -0
  7. lemming/api/conftest.py +204 -0
  8. lemming/api/context.py +39 -0
  9. lemming/api/context_test.py +62 -0
  10. lemming/api/directories.py +57 -0
  11. lemming/api/directories_test.py +94 -0
  12. lemming/api/files.py +116 -0
  13. lemming/api/files_test.py +163 -0
  14. lemming/api/hooks.py +15 -0
  15. lemming/api/hooks_test.py +33 -0
  16. lemming/api/logging.py +16 -0
  17. lemming/api/logging_test.py +59 -0
  18. lemming/api/loop.py +45 -0
  19. lemming/api/loop_test.py +58 -0
  20. lemming/api/main.py +53 -0
  21. lemming/api/main_test.py +30 -0
  22. lemming/api/tasks.py +170 -0
  23. lemming/api/tasks_test.py +426 -0
  24. lemming/api.py +5 -0
  25. lemming/api_test.py +7 -0
  26. lemming/cli/__init__.py +12 -0
  27. lemming/cli/config.py +67 -0
  28. lemming/cli/config_test.py +50 -0
  29. lemming/cli/context.py +40 -0
  30. lemming/cli/context_test.py +49 -0
  31. lemming/cli/hooks.py +147 -0
  32. lemming/cli/hooks_test.py +50 -0
  33. lemming/cli/main.py +42 -0
  34. lemming/cli/main_test.py +21 -0
  35. lemming/cli/operations.py +226 -0
  36. lemming/cli/operations_test.py +44 -0
  37. lemming/cli/progress.py +54 -0
  38. lemming/cli/progress_test.py +40 -0
  39. lemming/cli/readability_cli.py +28 -0
  40. lemming/cli/readability_cli_test.py +57 -0
  41. lemming/cli/tasks.py +529 -0
  42. lemming/cli/tasks_test.py +168 -0
  43. lemming/cli.py +5 -0
  44. lemming/cli_test.py +22 -0
  45. lemming/conftest.py +13 -0
  46. lemming/hooks.py +145 -0
  47. lemming/hooks_test.py +180 -0
  48. lemming/integration_test.py +299 -0
  49. lemming/main.py +6 -0
  50. lemming/main_test.py +44 -0
  51. lemming/models.py +88 -0
  52. lemming/models_test.py +91 -0
  53. lemming/orchestrator.py +407 -0
  54. lemming/orchestrator_test.py +468 -0
  55. lemming/paths.py +245 -0
  56. lemming/paths_test.py +186 -0
  57. lemming/persistence.py +179 -0
  58. lemming/persistence_test.py +150 -0
  59. lemming/prompts/hooks/readability.md +42 -0
  60. lemming/prompts/hooks/roadmap.md +61 -0
  61. lemming/prompts/hooks/testing.md +44 -0
  62. lemming/prompts/taskrunner.md +69 -0
  63. lemming/prompts.py +354 -0
  64. lemming/prompts_test.py +421 -0
  65. lemming/providers.py +190 -0
  66. lemming/providers_test.py +73 -0
  67. lemming/runner.py +327 -0
  68. lemming/runner_test.py +378 -0
  69. lemming/tasks/__init__.py +75 -0
  70. lemming/tasks/lifecycle.py +331 -0
  71. lemming/tasks/lifecycle_test.py +312 -0
  72. lemming/tasks/operations.py +258 -0
  73. lemming/tasks/operations_test.py +172 -0
  74. lemming/tasks/progress.py +29 -0
  75. lemming/tasks/progress_test.py +22 -0
  76. lemming/tasks/queries.py +128 -0
  77. lemming/tasks/queries_test.py +233 -0
  78. lemming/web/dashboard.spec.js +350 -0
  79. lemming/web/dashboard.test.js +998 -0
  80. lemming/web/favicon.js +40 -0
  81. lemming/web/favicon.spec.js +242 -0
  82. lemming/web/files.html +375 -0
  83. lemming/web/files.spec.js +97 -0
  84. lemming/web/index.html +983 -0
  85. lemming/web/index.js +753 -0
  86. lemming/web/logs.html +358 -0
  87. lemming/web/logs.test.js +195 -0
  88. lemming/web/mancha.js +58 -0
  89. lemming/web/screenshots.spec.js +328 -0
  90. lemming_cli-0.1.0.dist-info/METADATA +314 -0
  91. lemming_cli-0.1.0.dist-info/RECORD +94 -0
  92. lemming_cli-0.1.0.dist-info/WHEEL +4 -0
  93. lemming_cli-0.1.0.dist-info/entry_points.txt +2 -0
  94. lemming_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
lemming/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
@@ -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