lemming-cli 0.1.0__tar.gz → 0.1.1__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 (110) hide show
  1. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/PKG-INFO +18 -2
  2. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/README.md +17 -1
  3. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/pyproject.toml +1 -1
  4. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/runner.py +67 -14
  5. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/runner_test.py +103 -0
  6. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/tasks/lifecycle.py +77 -28
  7. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/tasks/lifecycle_test.py +77 -1
  8. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/uv.lock +1 -1
  9. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/.github/workflows/ci.yml +0 -0
  10. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/.github/workflows/publish.yml +0 -0
  11. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/.gitignore +0 -0
  12. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/.prettierignore +0 -0
  13. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/.prettierrc +0 -0
  14. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/Dockerfile +0 -0
  15. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/LICENSE +0 -0
  16. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/biome.json +0 -0
  17. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/docker-compose.yml +0 -0
  18. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/docs/HOOKS.md +0 -0
  19. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/docs/screenshots/dashboard-desktop.png +0 -0
  20. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/docs/screenshots/dashboard-mobile.png +0 -0
  21. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/docs/screenshots/task-log-desktop.png +0 -0
  22. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/docs/screenshots/task-log-mobile.png +0 -0
  23. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/package-lock.json +0 -0
  24. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/package.json +0 -0
  25. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/playwright.config.js +0 -0
  26. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/__init__.py +0 -0
  27. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/__init__.py +0 -0
  28. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/auth.py +0 -0
  29. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/auth_test.py +0 -0
  30. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/config.py +0 -0
  31. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/config_test.py +0 -0
  32. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/conftest.py +0 -0
  33. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/context.py +0 -0
  34. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/context_test.py +0 -0
  35. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/directories.py +0 -0
  36. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/directories_test.py +0 -0
  37. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/files.py +0 -0
  38. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/files_test.py +0 -0
  39. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/hooks.py +0 -0
  40. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/hooks_test.py +0 -0
  41. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/logging.py +0 -0
  42. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/logging_test.py +0 -0
  43. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/loop.py +0 -0
  44. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/loop_test.py +0 -0
  45. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/main.py +0 -0
  46. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/main_test.py +0 -0
  47. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/tasks.py +0 -0
  48. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/tasks_test.py +0 -0
  49. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api.py +0 -0
  50. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api_test.py +0 -0
  51. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/__init__.py +0 -0
  52. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/config.py +0 -0
  53. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/config_test.py +0 -0
  54. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/context.py +0 -0
  55. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/context_test.py +0 -0
  56. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/hooks.py +0 -0
  57. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/hooks_test.py +0 -0
  58. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/main.py +0 -0
  59. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/main_test.py +0 -0
  60. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/operations.py +0 -0
  61. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/operations_test.py +0 -0
  62. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/progress.py +0 -0
  63. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/progress_test.py +0 -0
  64. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/readability_cli.py +0 -0
  65. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/readability_cli_test.py +0 -0
  66. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/tasks.py +0 -0
  67. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/tasks_test.py +0 -0
  68. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli.py +0 -0
  69. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli_test.py +0 -0
  70. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/conftest.py +0 -0
  71. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/hooks.py +0 -0
  72. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/hooks_test.py +0 -0
  73. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/integration_test.py +0 -0
  74. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/main.py +0 -0
  75. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/main_test.py +0 -0
  76. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/models.py +0 -0
  77. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/models_test.py +0 -0
  78. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/orchestrator.py +0 -0
  79. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/orchestrator_test.py +0 -0
  80. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/paths.py +0 -0
  81. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/paths_test.py +0 -0
  82. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/persistence.py +0 -0
  83. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/persistence_test.py +0 -0
  84. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/prompts/hooks/readability.md +0 -0
  85. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/prompts/hooks/roadmap.md +0 -0
  86. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/prompts/hooks/testing.md +0 -0
  87. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/prompts/taskrunner.md +0 -0
  88. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/prompts.py +0 -0
  89. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/prompts_test.py +0 -0
  90. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/providers.py +0 -0
  91. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/providers_test.py +0 -0
  92. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/tasks/__init__.py +0 -0
  93. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/tasks/operations.py +0 -0
  94. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/tasks/operations_test.py +0 -0
  95. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/tasks/progress.py +0 -0
  96. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/tasks/progress_test.py +0 -0
  97. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/tasks/queries.py +0 -0
  98. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/tasks/queries_test.py +0 -0
  99. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/web/dashboard.spec.js +0 -0
  100. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/web/dashboard.test.js +0 -0
  101. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/web/favicon.js +0 -0
  102. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/web/favicon.spec.js +0 -0
  103. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/web/files.html +0 -0
  104. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/web/files.spec.js +0 -0
  105. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/web/index.html +0 -0
  106. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/web/index.js +0 -0
  107. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/web/logs.html +0 -0
  108. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/web/logs.test.js +0 -0
  109. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/web/mancha.js +0 -0
  110. {lemming_cli-0.1.0 → lemming_cli-0.1.1}/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.0
3
+ Version: 0.1.1
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
@@ -54,7 +54,7 @@ human-readable `tasks.yml` file.
54
54
  Install globally using `uv`:
55
55
 
56
56
  ```bash
57
- uv tool install git+https://github.com/owahltinez/lemming.git
57
+ uv tool install lemming-cli
58
58
  ```
59
59
 
60
60
  ## Quick Start in 3 Steps
@@ -303,6 +303,22 @@ prompt text and skips all default flag injection.
303
303
 
304
304
  ---
305
305
 
306
+ ## Releasing
307
+
308
+ Releases are published to PyPI as
309
+ [`lemming-cli`](https://pypi.org/project/lemming-cli/) via trusted publishing:
310
+ pushing a `v*` tag triggers the `publish.yml` GitHub Actions workflow, which
311
+ builds the package with `uv build` and uploads it.
312
+
313
+ ```bash
314
+ # 1. Bump the version in pyproject.toml, commit, and push
315
+ # 2. Tag the release and push the tag
316
+ git tag v0.1.1
317
+ git push origin v0.1.1
318
+ ```
319
+
320
+ ---
321
+
306
322
  ## Screenshots
307
323
 
308
324
  ### Dashboard
@@ -32,7 +32,7 @@ human-readable `tasks.yml` file.
32
32
  Install globally using `uv`:
33
33
 
34
34
  ```bash
35
- uv tool install git+https://github.com/owahltinez/lemming.git
35
+ uv tool install lemming-cli
36
36
  ```
37
37
 
38
38
  ## Quick Start in 3 Steps
@@ -281,6 +281,22 @@ prompt text and skips all default flag injection.
281
281
 
282
282
  ---
283
283
 
284
+ ## Releasing
285
+
286
+ Releases are published to PyPI as
287
+ [`lemming-cli`](https://pypi.org/project/lemming-cli/) via trusted publishing:
288
+ pushing a `v*` tag triggers the `publish.yml` GitHub Actions workflow, which
289
+ builds the package with `uv build` and uploads it.
290
+
291
+ ```bash
292
+ # 1. Bump the version in pyproject.toml, commit, and push
293
+ # 2. Tag the release and push the tag
294
+ git tag v0.1.1
295
+ git push origin v0.1.1
296
+ ```
297
+
298
+ ---
299
+
284
300
  ## Screenshots
285
301
 
286
302
  ### Dashboard
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "lemming-cli"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  description = "An autonomous, iterative task runner for AI coding agents."
9
9
  readme = "README.md"
10
10
  authors = [
@@ -17,6 +17,14 @@ logger = logging.getLogger(__name__)
17
17
  # Sentinel return codes for non-standard process termination.
18
18
  RETURNCODE_TIMEOUT = -14
19
19
 
20
+ # Grace period after SIGTERM before escalating to SIGKILL.
21
+ KILL_GRACE_SECONDS = 5
22
+
23
+ # How long to wait for EOF on the runner's stdout after it exits. A process
24
+ # that escaped the process group (e.g. by starting its own session) can
25
+ # inherit the pipe and hold it open indefinitely.
26
+ OUTPUT_DRAIN_SECONDS = 5
27
+
20
28
 
21
29
  def _pretty_quote(s: str) -> str:
22
30
  """Quotes a string for shell execution, preferring readable quoting.
@@ -177,19 +185,38 @@ def build_runner_command(
177
185
  def _kill_process_tree(process: subprocess.Popen) -> None:
178
186
  """Kills a process and its entire process group.
179
187
 
180
- Tries SIGTERM on the process group first, falls back to killing the
181
- process directly if the group signal fails.
188
+ Sends SIGTERM to the process group first so the runner can clean up,
189
+ then escalates to SIGKILL if the process is still alive after a grace
190
+ period. Falls back to killing the process directly if the group signal
191
+ fails.
182
192
 
183
193
  Args:
184
194
  process: The subprocess to kill.
185
195
  """
196
+ # Ask the whole group to terminate gracefully.
186
197
  try:
187
- os.killpg(os.getpgid(process.pid), signal.SIGTERM)
198
+ pgid = os.getpgid(process.pid)
199
+ os.killpg(pgid, signal.SIGTERM)
188
200
  except OSError:
189
201
  try:
190
202
  process.kill()
191
203
  except OSError:
192
204
  pass
205
+ return
206
+
207
+ # Escalate to SIGKILL if the process ignored SIGTERM.
208
+ try:
209
+ process.wait(timeout=KILL_GRACE_SECONDS)
210
+ except subprocess.TimeoutExpired:
211
+ logger.warning(
212
+ "Process %d survived SIGTERM for %ss; sending SIGKILL.",
213
+ process.pid,
214
+ KILL_GRACE_SECONDS,
215
+ )
216
+ try:
217
+ os.killpg(pgid, signal.SIGKILL)
218
+ except OSError:
219
+ pass
193
220
 
194
221
 
195
222
  def run_with_heartbeat(
@@ -264,6 +291,23 @@ def run_with_heartbeat(
264
291
 
265
292
  timed_out = False
266
293
 
294
+ def stream_output():
295
+ """Streams runner output to the log file and optionally the console."""
296
+ if not process.stdout:
297
+ return
298
+ with open(log_file, "a", encoding="utf-8") as f:
299
+ for line in process.stdout:
300
+ full_log.append(line)
301
+ f.write(line)
302
+ f.flush()
303
+ if verbose:
304
+ echo_fn(line)
305
+
306
+ # Read stdout on a separate thread so the main thread can wait on the
307
+ # process itself: reading until EOF can block long after the runner
308
+ # exits if an escaped child still holds the pipe open.
309
+ reader_thread = threading.Thread(target=stream_output, daemon=True)
310
+
267
311
  try:
268
312
  # Heartbeat and cancellation management
269
313
  is_claimed = tasks.update_heartbeat(
@@ -302,26 +346,35 @@ def run_with_heartbeat(
302
346
  )
303
347
  heartbeat_thread.start()
304
348
 
305
- # Stream output to log file and optionally to console
306
- try:
307
- if process.stdout:
308
- with open(log_file, "a", encoding="utf-8") as f:
309
- for line in process.stdout:
310
- full_log.append(line)
311
- f.write(line)
312
- f.flush()
313
- if verbose:
314
- echo_fn(line)
349
+ reader_thread.start()
315
350
 
351
+ try:
316
352
  process.wait()
317
353
  except BaseException:
318
354
  _kill_process_tree(process)
319
355
  raise
320
356
 
357
+ # Drain remaining output, but don't wait forever: an escaped child
358
+ # holding the pipe open must not wedge the attempt.
359
+ reader_thread.join(timeout=OUTPUT_DRAIN_SECONDS)
360
+ if reader_thread.is_alive():
361
+ logger.warning(
362
+ "Runner stdout still open %ss after exit for task %s; "
363
+ "abandoning output reader.",
364
+ OUTPUT_DRAIN_SECONDS,
365
+ task_id,
366
+ )
367
+
321
368
  returncode = process.returncode
322
369
  if timed_out:
323
370
  returncode = RETURNCODE_TIMEOUT
324
371
  return returncode, "".join(full_log), ""
325
372
  finally:
326
- if process.stdout and hasattr(process.stdout, "close"):
373
+ # Closing the stream while the reader is blocked on it can deadlock
374
+ # on the stream's internal lock, so only close it after EOF.
375
+ if (
376
+ process.stdout
377
+ and not reader_thread.is_alive()
378
+ and hasattr(process.stdout, "close")
379
+ ):
327
380
  process.stdout.close()
@@ -1,5 +1,6 @@
1
1
  import signal
2
2
  import subprocess
3
+ import sys
3
4
  import time
4
5
  import unittest.mock
5
6
 
@@ -376,3 +377,105 @@ def test_run_with_heartbeat_no_timeout(tmp_path):
376
377
  )
377
378
 
378
379
  assert returncode == 0
380
+
381
+
382
+ def test_kill_process_tree_escalates_to_sigkill():
383
+ """Verifies SIGKILL escalation when SIGTERM does not stop the process."""
384
+ process = unittest.mock.MagicMock(spec=subprocess.Popen)
385
+ process.pid = 12345
386
+ process.wait.side_effect = subprocess.TimeoutExpired(cmd="x", timeout=1)
387
+
388
+ with (
389
+ unittest.mock.patch("os.killpg") as mock_killpg,
390
+ unittest.mock.patch("os.getpgid", return_value=54321),
391
+ ):
392
+ runner._kill_process_tree(process)
393
+
394
+ assert mock_killpg.call_args_list == [
395
+ unittest.mock.call(54321, signal.SIGTERM),
396
+ unittest.mock.call(54321, signal.SIGKILL),
397
+ ]
398
+
399
+
400
+ def test_run_with_heartbeat_timeout_kills_sigterm_immune_runner(tmp_path):
401
+ """Verifies a runner that ignores SIGTERM is still killed on timeout."""
402
+ tasks_file = tmp_path / "tasks.yml"
403
+ task_id = "sigterm_immune"
404
+
405
+ roadmap = tasks.Roadmap(
406
+ tasks=[tasks.Task(id=task_id, description="test escalation")]
407
+ )
408
+ tasks.save_tasks(tasks_file, roadmap)
409
+ tasks.mark_task_in_progress(tasks_file, task_id)
410
+
411
+ # The runner ignores SIGTERM and signals readiness via a sentinel file.
412
+ sentinel = tmp_path / "ready"
413
+ child_code = (
414
+ "import pathlib, signal, time\n"
415
+ "signal.signal(signal.SIGTERM, signal.SIG_IGN)\n"
416
+ f"pathlib.Path({str(sentinel)!r}).touch()\n"
417
+ "time.sleep(300)\n"
418
+ )
419
+
420
+ # Jump the clock past the limit only once the child has installed its
421
+ # SIGTERM handler, so the kill cannot land before setup completes.
422
+ real_monotonic = time.monotonic
423
+
424
+ def fast_monotonic():
425
+ return real_monotonic() + (120 if sentinel.exists() else 0)
426
+
427
+ start = real_monotonic()
428
+ with (
429
+ unittest.mock.patch("time.monotonic", side_effect=fast_monotonic),
430
+ unittest.mock.patch.object(tasks, "STALE_THRESHOLD", 2),
431
+ unittest.mock.patch.object(runner, "KILL_GRACE_SECONDS", 1),
432
+ ):
433
+ returncode, _, _ = runner.run_with_heartbeat(
434
+ [sys.executable, "-c", child_code],
435
+ tasks_file,
436
+ task_id,
437
+ verbose=False,
438
+ time_limit=1,
439
+ )
440
+
441
+ assert returncode == runner.RETURNCODE_TIMEOUT
442
+ assert real_monotonic() - start < 30
443
+
444
+
445
+ def test_run_with_heartbeat_returns_when_grandchild_holds_stdout(tmp_path):
446
+ """Verifies return when a detached grandchild keeps the pipe open.
447
+
448
+ A process that starts its own session escapes the group kill and
449
+ inherits the stdout pipe; reading until EOF would block until it
450
+ exits, long after the runner itself is gone.
451
+ """
452
+ tasks_file = tmp_path / "tasks.yml"
453
+ task_id = "detached_grandchild"
454
+
455
+ roadmap = tasks.Roadmap(
456
+ tasks=[tasks.Task(id=task_id, description="test pipe hold")]
457
+ )
458
+ tasks.save_tasks(tasks_file, roadmap)
459
+
460
+ child_code = (
461
+ "import subprocess, sys\n"
462
+ "subprocess.Popen(\n"
463
+ " [sys.executable, '-c', 'import time; time.sleep(30)'],\n"
464
+ " start_new_session=True,\n"
465
+ ")\n"
466
+ "print('runner done', flush=True)\n"
467
+ )
468
+
469
+ start = time.monotonic()
470
+ with unittest.mock.patch.object(runner, "OUTPUT_DRAIN_SECONDS", 1):
471
+ returncode, stdout, _ = runner.run_with_heartbeat(
472
+ [sys.executable, "-c", child_code],
473
+ tasks_file,
474
+ task_id,
475
+ verbose=False,
476
+ time_limit=0,
477
+ )
478
+
479
+ assert returncode == 0
480
+ assert "runner done" in stdout
481
+ assert time.monotonic() - start < 10
@@ -1,5 +1,6 @@
1
1
  """Task lifecycle management: claiming, heartbeats, cancellation, resets."""
2
2
 
3
+ import logging
3
4
  import os
4
5
  import pathlib
5
6
  import secrets
@@ -8,9 +9,14 @@ import time
8
9
 
9
10
  from .. import models, paths, persistence
10
11
 
12
+ logger = logging.getLogger(__name__)
13
+
11
14
  # Re-export constants for internal/external use
12
15
  STALE_THRESHOLD = persistence.STALE_THRESHOLD
13
16
 
17
+ # Grace period after SIGTERM before escalating to SIGKILL.
18
+ KILL_GRACE_SECONDS = 5
19
+
14
20
 
15
21
  def generate_task_id() -> str:
16
22
  """Generates a random short hex string for a unique task ID.
@@ -252,6 +258,49 @@ def reset_task_logs(tasks_file: pathlib.Path, task_id: str) -> None:
252
258
  log_file.unlink()
253
259
 
254
260
 
261
+ def _kill_pid_tree(pid: int) -> None:
262
+ """Kills a process and its entire process group by PID.
263
+
264
+ Sends SIGTERM to the process group first so it can clean up, then
265
+ escalates to SIGKILL if the process is still alive after a grace
266
+ period. Unlike runner._kill_process_tree, this works from a bare PID
267
+ (no subprocess handle), so exit is detected by polling.
268
+
269
+ Args:
270
+ pid: The process ID to kill.
271
+ """
272
+ # Ask the whole group to terminate gracefully.
273
+ pgid = None
274
+ try:
275
+ pgid = os.getpgid(pid)
276
+ os.killpg(pgid, signal.SIGTERM)
277
+ except OSError:
278
+ try:
279
+ os.kill(pid, signal.SIGTERM)
280
+ except OSError:
281
+ return
282
+
283
+ # Poll for exit, then escalate to SIGKILL if SIGTERM was ignored.
284
+ deadline = time.monotonic() + KILL_GRACE_SECONDS
285
+ while time.monotonic() < deadline:
286
+ if not is_pid_alive(pid):
287
+ return
288
+ time.sleep(0.1)
289
+
290
+ logger.warning(
291
+ "Process %d survived SIGTERM for %ss; sending SIGKILL.",
292
+ pid,
293
+ KILL_GRACE_SECONDS,
294
+ )
295
+ try:
296
+ if pgid is not None:
297
+ os.killpg(pgid, signal.SIGKILL)
298
+ else:
299
+ os.kill(pid, signal.SIGKILL)
300
+ except OSError:
301
+ pass
302
+
303
+
255
304
  def cancel_task(tasks_file: pathlib.Path, task_id: str) -> bool:
256
305
  """Kill the task process AND the orchestrator loop, then mark cancelled.
257
306
 
@@ -263,39 +312,39 @@ def cancel_task(tasks_file: pathlib.Path, task_id: str) -> bool:
263
312
  True if the task was found and cancellation was attempted, False
264
313
  otherwise.
265
314
  """
315
+ # Update the task state first and release the lock: the kill below can
316
+ # wait out a grace period and must not stall heartbeats or readers.
317
+ task_pid = None
318
+ loop_pid = None
266
319
  with persistence.lock_tasks(tasks_file):
267
320
  data = persistence.load_tasks(tasks_file)
321
+ task = next((t for t in data.tasks if t.id == task_id), None)
322
+ if not task:
323
+ return False
268
324
 
269
- for task in data.tasks:
270
- if task.id == task_id:
271
- # First kill the task itself
272
- if task.pid:
273
- try:
274
- # Try to kill the whole process group if possible
275
- os.killpg(os.getpgid(task.pid), signal.SIGTERM)
276
- except OSError:
277
- try:
278
- os.kill(task.pid, signal.SIGTERM)
279
- except OSError:
280
- pass
281
-
282
- # Also kill the orchestrator loop if it's running
283
- loop_pid = persistence.get_loop_pid(tasks_file)
284
- if loop_pid:
285
- try:
286
- os.kill(loop_pid, signal.SIGTERM)
287
- except OSError:
288
- pass
325
+ task_pid = task.pid
326
+ loop_pid = persistence.get_loop_pid(tasks_file)
289
327
 
290
- update_run_time(task)
291
- task.status = models.TaskStatus.CANCELLED
292
- task.pid = None
293
- task.last_heartbeat = None
294
- task.requested_status = None
328
+ update_run_time(task)
329
+ task.status = models.TaskStatus.CANCELLED
330
+ task.pid = None
331
+ task.last_heartbeat = None
332
+ task.requested_status = None
295
333
 
296
- persistence.save_tasks(tasks_file, data)
297
- return True
298
- return False
334
+ persistence.save_tasks(tasks_file, data)
335
+
336
+ # Kill the orchestrator loop first so it cannot pick up the next task
337
+ # while we wait for the task process tree to die.
338
+ if loop_pid:
339
+ try:
340
+ os.kill(loop_pid, signal.SIGTERM)
341
+ except OSError:
342
+ pass
343
+
344
+ if task_pid:
345
+ _kill_pid_tree(task_pid)
346
+
347
+ return True
299
348
 
300
349
 
301
350
  def reset_task(tasks_file: pathlib.Path, task_id: str) -> models.Task:
@@ -168,7 +168,10 @@ def test_cancel_task(tmp_path):
168
168
  )
169
169
  persistence.save_tasks(tasks_file, data)
170
170
 
171
- with patch("lemming.tasks.lifecycle.os.killpg"):
171
+ with (
172
+ patch("lemming.tasks.lifecycle.os.killpg"),
173
+ patch("lemming.tasks.lifecycle.is_pid_alive", return_value=False),
174
+ ):
172
175
  success = lifecycle.cancel_task(tasks_file, "1")
173
176
  assert success is True
174
177
 
@@ -197,6 +200,7 @@ def test_cancel_task_kills_loop_pid(tmp_path):
197
200
  patch("lemming.tasks.lifecycle.os.getpgid", return_value=123),
198
201
  patch("lemming.tasks.lifecycle.os.kill") as mock_kill,
199
202
  patch("lemming.tasks.lifecycle.os.killpg") as mock_killpg,
203
+ patch("lemming.tasks.lifecycle.is_pid_alive", return_value=False),
200
204
  ):
201
205
  success = lifecycle.cancel_task(tasks_file, "1")
202
206
  assert success is True
@@ -211,6 +215,78 @@ def test_cancel_task_kills_loop_pid(tmp_path):
211
215
  assert updated_data.tasks[0].status == models.TaskStatus.CANCELLED
212
216
 
213
217
 
218
+ def test_kill_pid_tree_escalates_to_sigkill():
219
+ """Verifies SIGKILL escalation when SIGTERM does not stop the process."""
220
+ with (
221
+ patch("lemming.tasks.lifecycle.os.getpgid", return_value=54321),
222
+ patch("lemming.tasks.lifecycle.os.killpg") as mock_killpg,
223
+ patch("lemming.tasks.lifecycle.is_pid_alive", return_value=True),
224
+ patch.object(lifecycle, "KILL_GRACE_SECONDS", 0),
225
+ ):
226
+ lifecycle._kill_pid_tree(12345)
227
+
228
+ assert mock_killpg.call_args_list == [
229
+ ((54321, signal.SIGTERM),),
230
+ ((54321, signal.SIGKILL),),
231
+ ]
232
+
233
+
234
+ def test_kill_pid_tree_no_escalation_when_process_exits():
235
+ """Verifies no SIGKILL is sent when the process exits on SIGTERM."""
236
+ with (
237
+ patch("lemming.tasks.lifecycle.os.getpgid", return_value=54321),
238
+ patch("lemming.tasks.lifecycle.os.killpg") as mock_killpg,
239
+ patch("lemming.tasks.lifecycle.is_pid_alive", return_value=False),
240
+ ):
241
+ lifecycle._kill_pid_tree(12345)
242
+
243
+ mock_killpg.assert_called_once_with(54321, signal.SIGTERM)
244
+
245
+
246
+ def test_cancel_task_escalates_sigterm_immune_process(tmp_path):
247
+ """Verifies cancellation kills a process that ignores SIGTERM.
248
+
249
+ Also verifies the task state is saved as CANCELLED before any signal
250
+ is sent, since the kill (which can wait out a grace period) must
251
+ happen outside the tasks-file lock.
252
+ """
253
+ tasks_file = tmp_path / "tasks.yml"
254
+ data = models.Roadmap(
255
+ tasks=[
256
+ models.Task(
257
+ id="1",
258
+ description="Task 1",
259
+ status=models.TaskStatus.IN_PROGRESS,
260
+ pid=12345,
261
+ )
262
+ ],
263
+ )
264
+ persistence.save_tasks(tasks_file, data)
265
+
266
+ status_at_first_signal = []
267
+
268
+ def record_status(*args):
269
+ saved = persistence.load_tasks(tasks_file)
270
+ status_at_first_signal.append(saved.tasks[0].status)
271
+
272
+ with (
273
+ patch("lemming.tasks.lifecycle.os.getpgid", return_value=54321),
274
+ patch(
275
+ "lemming.tasks.lifecycle.os.killpg", side_effect=record_status
276
+ ) as mock_killpg,
277
+ patch("lemming.tasks.lifecycle.is_pid_alive", return_value=True),
278
+ patch.object(lifecycle, "KILL_GRACE_SECONDS", 0),
279
+ ):
280
+ success = lifecycle.cancel_task(tasks_file, "1")
281
+
282
+ assert success is True
283
+ assert mock_killpg.call_args_list == [
284
+ ((54321, signal.SIGTERM),),
285
+ ((54321, signal.SIGKILL),),
286
+ ]
287
+ assert status_at_first_signal[0] == models.TaskStatus.CANCELLED
288
+
289
+
214
290
  def test_reset_task(tmp_path):
215
291
  tasks_file = tmp_path / "tasks.yml"
216
292
  task_id = "12345678"
@@ -222,7 +222,7 @@ wheels = [
222
222
 
223
223
  [[package]]
224
224
  name = "lemming-cli"
225
- version = "0.1.0"
225
+ version = "0.1.1"
226
226
  source = { editable = "." }
227
227
  dependencies = [
228
228
  { name = "beautifulsoup4" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes