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.
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/PKG-INFO +18 -2
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/README.md +17 -1
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/pyproject.toml +1 -1
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/runner.py +67 -14
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/runner_test.py +103 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/tasks/lifecycle.py +77 -28
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/tasks/lifecycle_test.py +77 -1
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/uv.lock +1 -1
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/.github/workflows/ci.yml +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/.github/workflows/publish.yml +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/.gitignore +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/.prettierignore +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/.prettierrc +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/Dockerfile +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/LICENSE +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/biome.json +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/docker-compose.yml +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/docs/HOOKS.md +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/docs/screenshots/dashboard-desktop.png +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/docs/screenshots/dashboard-mobile.png +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/docs/screenshots/task-log-desktop.png +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/docs/screenshots/task-log-mobile.png +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/package-lock.json +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/package.json +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/playwright.config.js +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/__init__.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/__init__.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/auth.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/auth_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/config.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/config_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/conftest.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/context.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/context_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/directories.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/directories_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/files.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/files_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/hooks.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/hooks_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/logging.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/logging_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/loop.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/loop_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/main.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/main_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/tasks.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api/tasks_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/api_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/__init__.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/config.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/config_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/context.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/context_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/hooks.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/hooks_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/main.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/main_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/operations.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/operations_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/progress.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/progress_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/readability_cli.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/readability_cli_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/tasks.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli/tasks_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/cli_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/conftest.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/hooks.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/hooks_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/integration_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/main.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/main_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/models.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/models_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/orchestrator.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/orchestrator_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/paths.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/paths_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/persistence.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/persistence_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/prompts/hooks/readability.md +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/prompts/hooks/roadmap.md +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/prompts/hooks/testing.md +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/prompts/taskrunner.md +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/prompts.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/prompts_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/providers.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/providers_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/tasks/__init__.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/tasks/operations.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/tasks/operations_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/tasks/progress.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/tasks/progress_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/tasks/queries.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/tasks/queries_test.py +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/web/dashboard.spec.js +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/web/dashboard.test.js +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/web/favicon.js +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/web/favicon.spec.js +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/web/files.html +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/web/files.spec.js +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/web/index.html +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/web/index.js +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/web/logs.html +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/web/logs.test.js +0 -0
- {lemming_cli-0.1.0 → lemming_cli-0.1.1}/src/lemming/web/mancha.js +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
@@ -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
|
-
|
|
181
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|