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.
- lemming/__init__.py +1 -0
- lemming/api/__init__.py +5 -0
- lemming/api/auth.py +34 -0
- lemming/api/auth_test.py +40 -0
- lemming/api/config.py +85 -0
- lemming/api/config_test.py +84 -0
- lemming/api/conftest.py +204 -0
- lemming/api/context.py +39 -0
- lemming/api/context_test.py +62 -0
- lemming/api/directories.py +57 -0
- lemming/api/directories_test.py +94 -0
- lemming/api/files.py +116 -0
- lemming/api/files_test.py +163 -0
- lemming/api/hooks.py +15 -0
- lemming/api/hooks_test.py +33 -0
- lemming/api/logging.py +16 -0
- lemming/api/logging_test.py +59 -0
- lemming/api/loop.py +45 -0
- lemming/api/loop_test.py +58 -0
- lemming/api/main.py +53 -0
- lemming/api/main_test.py +30 -0
- lemming/api/tasks.py +170 -0
- lemming/api/tasks_test.py +426 -0
- lemming/api.py +5 -0
- lemming/api_test.py +7 -0
- lemming/cli/__init__.py +12 -0
- lemming/cli/config.py +67 -0
- lemming/cli/config_test.py +50 -0
- lemming/cli/context.py +40 -0
- lemming/cli/context_test.py +49 -0
- lemming/cli/hooks.py +147 -0
- lemming/cli/hooks_test.py +50 -0
- lemming/cli/main.py +42 -0
- lemming/cli/main_test.py +21 -0
- lemming/cli/operations.py +226 -0
- lemming/cli/operations_test.py +44 -0
- lemming/cli/progress.py +54 -0
- lemming/cli/progress_test.py +40 -0
- lemming/cli/readability_cli.py +28 -0
- lemming/cli/readability_cli_test.py +57 -0
- lemming/cli/tasks.py +529 -0
- lemming/cli/tasks_test.py +168 -0
- lemming/cli.py +5 -0
- lemming/cli_test.py +22 -0
- lemming/conftest.py +13 -0
- lemming/hooks.py +145 -0
- lemming/hooks_test.py +180 -0
- lemming/integration_test.py +299 -0
- lemming/main.py +6 -0
- lemming/main_test.py +44 -0
- lemming/models.py +88 -0
- lemming/models_test.py +91 -0
- lemming/orchestrator.py +407 -0
- lemming/orchestrator_test.py +468 -0
- lemming/paths.py +245 -0
- lemming/paths_test.py +186 -0
- lemming/persistence.py +179 -0
- lemming/persistence_test.py +150 -0
- lemming/prompts/hooks/readability.md +42 -0
- lemming/prompts/hooks/roadmap.md +61 -0
- lemming/prompts/hooks/testing.md +44 -0
- lemming/prompts/taskrunner.md +69 -0
- lemming/prompts.py +354 -0
- lemming/prompts_test.py +421 -0
- lemming/providers.py +190 -0
- lemming/providers_test.py +73 -0
- lemming/runner.py +327 -0
- lemming/runner_test.py +378 -0
- lemming/tasks/__init__.py +75 -0
- lemming/tasks/lifecycle.py +331 -0
- lemming/tasks/lifecycle_test.py +312 -0
- lemming/tasks/operations.py +258 -0
- lemming/tasks/operations_test.py +172 -0
- lemming/tasks/progress.py +29 -0
- lemming/tasks/progress_test.py +22 -0
- lemming/tasks/queries.py +128 -0
- lemming/tasks/queries_test.py +233 -0
- lemming/web/dashboard.spec.js +350 -0
- lemming/web/dashboard.test.js +998 -0
- lemming/web/favicon.js +40 -0
- lemming/web/favicon.spec.js +242 -0
- lemming/web/files.html +375 -0
- lemming/web/files.spec.js +97 -0
- lemming/web/index.html +983 -0
- lemming/web/index.js +753 -0
- lemming/web/logs.html +358 -0
- lemming/web/logs.test.js +195 -0
- lemming/web/mancha.js +58 -0
- lemming/web/screenshots.spec.js +328 -0
- lemming_cli-0.1.0.dist-info/METADATA +314 -0
- lemming_cli-0.1.0.dist-info/RECORD +94 -0
- lemming_cli-0.1.0.dist-info/WHEEL +4 -0
- lemming_cli-0.1.0.dist-info/entry_points.txt +2 -0
- lemming_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
lemming/runner.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Runner command construction and subprocess execution with heartbeats."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import pathlib
|
|
6
|
+
import shlex
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from typing import Callable
|
|
12
|
+
|
|
13
|
+
from . import paths, tasks
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# Sentinel return codes for non-standard process termination.
|
|
18
|
+
RETURNCODE_TIMEOUT = -14
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _pretty_quote(s: str) -> str:
|
|
22
|
+
"""Quotes a string for shell execution, preferring readable quoting.
|
|
23
|
+
|
|
24
|
+
Strings containing single quotes are wrapped in double quotes for
|
|
25
|
+
readability. This function is idempotent for single shell words: if s
|
|
26
|
+
is already a valid shell-quoted single word, it is unquoted before
|
|
27
|
+
being re-quoted prettily.
|
|
28
|
+
"""
|
|
29
|
+
if not s:
|
|
30
|
+
return "''"
|
|
31
|
+
|
|
32
|
+
# If it's already a single shell word, try to unquote it first to avoid
|
|
33
|
+
# compounding. We only unquote if shlex.split succeeds and returns
|
|
34
|
+
# exactly one token.
|
|
35
|
+
try:
|
|
36
|
+
parts = shlex.split(s)
|
|
37
|
+
if len(parts) == 1:
|
|
38
|
+
# Check if it was actually quoted or escaped.
|
|
39
|
+
# shlex.split("foo") is "foo", but shlex.split("'foo'") is also
|
|
40
|
+
# "foo". We want to canonicalize any quoted string back to its
|
|
41
|
+
# literal form.
|
|
42
|
+
if parts[0] != s or (s.startswith("'") or s.startswith('"')):
|
|
43
|
+
s = parts[0]
|
|
44
|
+
except Exception:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
# If shlex.quote says it doesn't need quotes, return as-is
|
|
48
|
+
if shlex.quote(s) == s:
|
|
49
|
+
return s
|
|
50
|
+
|
|
51
|
+
# If it contains single quotes, try to use double quotes for better
|
|
52
|
+
# readability
|
|
53
|
+
if "'" in s:
|
|
54
|
+
# If it has !, double quotes might trigger history expansion in
|
|
55
|
+
# interactive bash
|
|
56
|
+
if "!" in s:
|
|
57
|
+
return shlex.quote(s)
|
|
58
|
+
|
|
59
|
+
# We need to escape \, ", $, ` inside double quotes
|
|
60
|
+
escaped = (
|
|
61
|
+
s.replace("\\", "\\\\")
|
|
62
|
+
.replace('"', '\\"')
|
|
63
|
+
.replace("$", "\\$")
|
|
64
|
+
.replace("`", "\\`")
|
|
65
|
+
)
|
|
66
|
+
return f'"{escaped}"'
|
|
67
|
+
|
|
68
|
+
# Default to standard shlex.quote
|
|
69
|
+
return shlex.quote(s)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _shlex_join_pretty(cmd: list[str], max_len: int = -1) -> str:
|
|
73
|
+
"""Joins command arguments into a single string with pretty quoting.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
cmd: List of command arguments.
|
|
77
|
+
max_len: If > 0, truncate each quoted argument to this length.
|
|
78
|
+
"""
|
|
79
|
+
parts = []
|
|
80
|
+
for arg in cmd:
|
|
81
|
+
quoted = _pretty_quote(arg)
|
|
82
|
+
if max_len > 0 and len(quoted) > max_len:
|
|
83
|
+
parts.append(quoted[:max_len] + "... [truncated]")
|
|
84
|
+
else:
|
|
85
|
+
parts.append(quoted)
|
|
86
|
+
return " ".join(parts)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_runner_command(
|
|
90
|
+
runner_name: str,
|
|
91
|
+
prompt: str,
|
|
92
|
+
yolo: bool,
|
|
93
|
+
runner_args: tuple | None = None,
|
|
94
|
+
no_defaults: bool = False,
|
|
95
|
+
verbose: bool = False,
|
|
96
|
+
time_limit: int = 0,
|
|
97
|
+
) -> list[str]:
|
|
98
|
+
"""Constructs the CLI command for the specified runner.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
runner_name: Name or path of the runner executable. May contain a
|
|
102
|
+
``{{prompt}}`` placeholder; when present, the template is
|
|
103
|
+
shlex-split and the placeholder token is replaced with the
|
|
104
|
+
prompt text. Default flag injection is skipped in template
|
|
105
|
+
mode.
|
|
106
|
+
prompt: The full prompt text to pass to the runner.
|
|
107
|
+
yolo: Whether to enable auto-approval/YOLO mode.
|
|
108
|
+
runner_args: Extra arguments to pass to the runner.
|
|
109
|
+
no_defaults: If True, do not inject default flags for known runners.
|
|
110
|
+
verbose: If True, enable verbose output for supported runners.
|
|
111
|
+
time_limit: Task time limit in minutes; used to size runner-side
|
|
112
|
+
timeouts for runners that enforce their own (0 means no limit).
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
A list of command-line arguments.
|
|
116
|
+
"""
|
|
117
|
+
# Template mode: {{prompt}} in runner_name means the user controls the
|
|
118
|
+
# full command layout. Split, substitute, and return early.
|
|
119
|
+
if "{{prompt}}" in runner_name:
|
|
120
|
+
parts = shlex.split(runner_name)
|
|
121
|
+
cmd = [p.replace("{{prompt}}", prompt) for p in parts]
|
|
122
|
+
if runner_args:
|
|
123
|
+
cmd.extend(runner_args)
|
|
124
|
+
return cmd
|
|
125
|
+
|
|
126
|
+
parts = shlex.split(runner_name)
|
|
127
|
+
cmd = [parts[0]]
|
|
128
|
+
extra_parts = parts[1:]
|
|
129
|
+
prompt_arg = None
|
|
130
|
+
|
|
131
|
+
runner_base = os.path.basename(parts[0])
|
|
132
|
+
|
|
133
|
+
if not no_defaults:
|
|
134
|
+
if runner_base.startswith("agy"):
|
|
135
|
+
if yolo:
|
|
136
|
+
cmd.append("--dangerously-skip-permissions")
|
|
137
|
+
|
|
138
|
+
# Print mode buffers stdout until the run completes, so stream the
|
|
139
|
+
# internal log to stdout for live visibility in the task log.
|
|
140
|
+
cmd.extend(["--log-file", "/dev/stdout"])
|
|
141
|
+
|
|
142
|
+
# agy caps print mode at 5 minutes by default; extend it to the
|
|
143
|
+
# task time limit (or effectively unlimited when there is none).
|
|
144
|
+
print_timeout = f"{time_limit}m" if time_limit > 0 else "24h"
|
|
145
|
+
cmd.extend(["--print-timeout", print_timeout])
|
|
146
|
+
|
|
147
|
+
prompt_arg = "--prompt"
|
|
148
|
+
elif runner_base.startswith("aider"):
|
|
149
|
+
if yolo:
|
|
150
|
+
cmd.append("--yes")
|
|
151
|
+
if not verbose:
|
|
152
|
+
cmd.append("--quiet")
|
|
153
|
+
prompt_arg = "--message"
|
|
154
|
+
elif runner_base.startswith("claude"):
|
|
155
|
+
if yolo:
|
|
156
|
+
cmd.append("--dangerously-skip-permissions")
|
|
157
|
+
cmd.extend(["--output-format=stream-json", "--verbose"])
|
|
158
|
+
prompt_arg = "--print"
|
|
159
|
+
elif runner_base.startswith("codex"):
|
|
160
|
+
if yolo:
|
|
161
|
+
cmd.append("--yolo")
|
|
162
|
+
prompt_arg = "--instructions"
|
|
163
|
+
|
|
164
|
+
if extra_parts:
|
|
165
|
+
cmd.extend(extra_parts)
|
|
166
|
+
if runner_args:
|
|
167
|
+
cmd.extend(runner_args)
|
|
168
|
+
|
|
169
|
+
if prompt_arg:
|
|
170
|
+
cmd.extend([prompt_arg, prompt])
|
|
171
|
+
else:
|
|
172
|
+
cmd.append(prompt)
|
|
173
|
+
|
|
174
|
+
return cmd
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _kill_process_tree(process: subprocess.Popen) -> None:
|
|
178
|
+
"""Kills a process and its entire process group.
|
|
179
|
+
|
|
180
|
+
Tries SIGTERM on the process group first, falls back to killing the
|
|
181
|
+
process directly if the group signal fails.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
process: The subprocess to kill.
|
|
185
|
+
"""
|
|
186
|
+
try:
|
|
187
|
+
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
|
188
|
+
except OSError:
|
|
189
|
+
try:
|
|
190
|
+
process.kill()
|
|
191
|
+
except OSError:
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def run_with_heartbeat(
|
|
196
|
+
cmd: list[str],
|
|
197
|
+
tasks_file: pathlib.Path,
|
|
198
|
+
task_id: str,
|
|
199
|
+
verbose: bool,
|
|
200
|
+
echo_fn: Callable[[str], None] = print,
|
|
201
|
+
cwd: pathlib.Path | None = None,
|
|
202
|
+
header: str | None = None,
|
|
203
|
+
time_limit: int = 0,
|
|
204
|
+
) -> tuple[int, str, str]:
|
|
205
|
+
"""Runs the runner process and updates the task heartbeat periodically.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
cmd: The command to execute as a list of strings.
|
|
209
|
+
tasks_file: Path to the tasks YAML file.
|
|
210
|
+
task_id: ID of the task being executed (used for heartbeat and
|
|
211
|
+
parent env var).
|
|
212
|
+
verbose: If True, echo runner output to the console.
|
|
213
|
+
echo_fn: Function to use for echoing output (defaults to print).
|
|
214
|
+
cwd: Optional working directory for the subprocess.
|
|
215
|
+
header: Optional header to write to the log (e.g. "Orchestrator
|
|
216
|
+
Hook: roadmap").
|
|
217
|
+
time_limit: Maximum execution time in minutes. 0 means no limit.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
A tuple of (returncode, stdout_log, stderr_log). Note: stderr is
|
|
221
|
+
currently merged into stdout_log.
|
|
222
|
+
"""
|
|
223
|
+
log_file = paths.get_log_file(tasks_file, task_id)
|
|
224
|
+
|
|
225
|
+
# Use a separator for new attempts or hooks
|
|
226
|
+
# For the log file, we truncate long arguments (like the prompt) to
|
|
227
|
+
# avoid unreadable logs and exponential escaping growth when prompts
|
|
228
|
+
# are re-quoted.
|
|
229
|
+
log_command_str = _shlex_join_pretty(cmd, max_len=200)
|
|
230
|
+
command_str = _shlex_join_pretty(cmd)
|
|
231
|
+
|
|
232
|
+
with open(log_file, "a", encoding="utf-8") as f:
|
|
233
|
+
timestamp = time.strftime("%Y-%m-%d %H:%M:%S %Z")
|
|
234
|
+
f.write(f"\n--- Attempt started at {timestamp} ---\n")
|
|
235
|
+
if header:
|
|
236
|
+
f.write(f"{'=' * 80}\n")
|
|
237
|
+
f.write(f"{header.upper()} started at {timestamp}\n")
|
|
238
|
+
f.write(f"{'=' * 80}\n")
|
|
239
|
+
f.write(f"Command: {log_command_str}\n")
|
|
240
|
+
f.flush()
|
|
241
|
+
|
|
242
|
+
if verbose:
|
|
243
|
+
echo_fn(f"Executing: {command_str}\n\n")
|
|
244
|
+
|
|
245
|
+
# Start the process in a new session so we can kill its entire process
|
|
246
|
+
# tree if needed.
|
|
247
|
+
env = os.environ.copy()
|
|
248
|
+
env["LEMMING_PARENT_TASK_ID"] = task_id
|
|
249
|
+
env["LEMMING_PARENT_TASKS_FILE"] = str(tasks_file.resolve())
|
|
250
|
+
|
|
251
|
+
process = subprocess.Popen(
|
|
252
|
+
cmd,
|
|
253
|
+
env=env,
|
|
254
|
+
stdout=subprocess.PIPE,
|
|
255
|
+
stderr=subprocess.STDOUT,
|
|
256
|
+
text=True,
|
|
257
|
+
bufsize=1,
|
|
258
|
+
errors="replace",
|
|
259
|
+
start_new_session=True,
|
|
260
|
+
cwd=cwd,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
full_log: list[str] = []
|
|
264
|
+
|
|
265
|
+
timed_out = False
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
# Heartbeat and cancellation management
|
|
269
|
+
is_claimed = tasks.update_heartbeat(
|
|
270
|
+
tasks_file, task_id, pid=process.pid
|
|
271
|
+
)
|
|
272
|
+
start_time = time.monotonic()
|
|
273
|
+
|
|
274
|
+
def heartbeat_loop():
|
|
275
|
+
"""Updates the task heartbeat while the process is running."""
|
|
276
|
+
nonlocal timed_out
|
|
277
|
+
while process.poll() is None:
|
|
278
|
+
if not tasks.update_heartbeat(tasks_file, task_id):
|
|
279
|
+
# Task was cancelled or finished.
|
|
280
|
+
_kill_process_tree(process)
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
# Check time limit
|
|
284
|
+
if time_limit > 0:
|
|
285
|
+
elapsed = time.monotonic() - start_time
|
|
286
|
+
if elapsed >= time_limit * 60:
|
|
287
|
+
timed_out = True
|
|
288
|
+
tasks.add_progress(
|
|
289
|
+
tasks_file,
|
|
290
|
+
task_id,
|
|
291
|
+
f"Task killed: time limit of {time_limit}"
|
|
292
|
+
" minutes reached.",
|
|
293
|
+
)
|
|
294
|
+
_kill_process_tree(process)
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
time.sleep(tasks.STALE_THRESHOLD // 2)
|
|
298
|
+
|
|
299
|
+
if is_claimed:
|
|
300
|
+
heartbeat_thread = threading.Thread(
|
|
301
|
+
target=heartbeat_loop, daemon=True
|
|
302
|
+
)
|
|
303
|
+
heartbeat_thread.start()
|
|
304
|
+
|
|
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)
|
|
315
|
+
|
|
316
|
+
process.wait()
|
|
317
|
+
except BaseException:
|
|
318
|
+
_kill_process_tree(process)
|
|
319
|
+
raise
|
|
320
|
+
|
|
321
|
+
returncode = process.returncode
|
|
322
|
+
if timed_out:
|
|
323
|
+
returncode = RETURNCODE_TIMEOUT
|
|
324
|
+
return returncode, "".join(full_log), ""
|
|
325
|
+
finally:
|
|
326
|
+
if process.stdout and hasattr(process.stdout, "close"):
|
|
327
|
+
process.stdout.close()
|
lemming/runner_test.py
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import signal
|
|
2
|
+
import subprocess
|
|
3
|
+
import time
|
|
4
|
+
import unittest.mock
|
|
5
|
+
|
|
6
|
+
from lemming import paths, runner, tasks
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_build_runner_command_agy():
|
|
10
|
+
cmd = runner.build_runner_command("agy", "my prompt", yolo=True)
|
|
11
|
+
assert "--dangerously-skip-permissions" in cmd
|
|
12
|
+
assert "--prompt" in cmd
|
|
13
|
+
assert "my prompt" in cmd
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_build_runner_command_agy_streams_internal_log():
|
|
17
|
+
# agy print mode buffers stdout until the end, so its internal log is
|
|
18
|
+
# redirected to stdout to give live visibility in the task log.
|
|
19
|
+
cmd = runner.build_runner_command("agy", "my prompt", yolo=True)
|
|
20
|
+
assert cmd[cmd.index("--log-file") + 1] == "/dev/stdout"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_build_runner_command_agy_print_timeout_matches_time_limit():
|
|
24
|
+
cmd = runner.build_runner_command(
|
|
25
|
+
"agy", "my prompt", yolo=True, time_limit=45
|
|
26
|
+
)
|
|
27
|
+
assert cmd[cmd.index("--print-timeout") + 1] == "45m"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_build_runner_command_agy_print_timeout_without_time_limit():
|
|
31
|
+
# agy defaults --print-timeout to 5m, so an explicit large value is
|
|
32
|
+
# required even when the task has no time limit.
|
|
33
|
+
cmd = runner.build_runner_command(
|
|
34
|
+
"agy", "my prompt", yolo=True, time_limit=0
|
|
35
|
+
)
|
|
36
|
+
assert cmd[cmd.index("--print-timeout") + 1] == "24h"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_build_runner_command_time_limit_ignored_by_other_runners():
|
|
40
|
+
cmd = runner.build_runner_command(
|
|
41
|
+
"claude", "my prompt", yolo=True, time_limit=45
|
|
42
|
+
)
|
|
43
|
+
assert "--print-timeout" not in cmd
|
|
44
|
+
assert "--log-file" not in cmd
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_build_runner_command_aider():
|
|
48
|
+
cmd = runner.build_runner_command("aider", "my prompt", yolo=True)
|
|
49
|
+
assert "--yes" in cmd
|
|
50
|
+
assert "--message" in cmd
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_build_runner_command_with_flags_in_name():
|
|
54
|
+
cmd = runner.build_runner_command(
|
|
55
|
+
"claude-corp -- --output-format=stream-json", "my prompt", yolo=True
|
|
56
|
+
)
|
|
57
|
+
assert cmd[0] == "claude-corp"
|
|
58
|
+
assert "--" in cmd
|
|
59
|
+
assert "--output-format=stream-json" in cmd
|
|
60
|
+
assert "--dangerously-skip-permissions" in cmd
|
|
61
|
+
assert "--print" in cmd
|
|
62
|
+
assert "my prompt" in cmd
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_build_runner_command_with_quoted_flags_in_name():
|
|
66
|
+
cmd = runner.build_runner_command(
|
|
67
|
+
'my-runner --model "gpt 4"', "my prompt", yolo=True, no_defaults=True
|
|
68
|
+
)
|
|
69
|
+
assert cmd[0] == "my-runner"
|
|
70
|
+
assert "--model" in cmd
|
|
71
|
+
assert "gpt 4" in cmd
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_build_runner_command_template_basic():
|
|
75
|
+
cmd = runner.build_runner_command(
|
|
76
|
+
"my-tool --input={{prompt}} --json", "hello world", yolo=True
|
|
77
|
+
)
|
|
78
|
+
assert cmd == ["my-tool", "--input=hello world", "--json"]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_build_runner_command_template_standalone_placeholder():
|
|
82
|
+
cmd = runner.build_runner_command(
|
|
83
|
+
"my-tool --flag {{prompt}}", "hello world", yolo=True
|
|
84
|
+
)
|
|
85
|
+
assert cmd == ["my-tool", "--flag", "hello world"]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_build_runner_command_template_with_runner_args():
|
|
89
|
+
cmd = runner.build_runner_command(
|
|
90
|
+
"my-tool {{prompt}}", "hello", yolo=True, runner_args=("--extra",)
|
|
91
|
+
)
|
|
92
|
+
assert cmd == ["my-tool", "hello", "--extra"]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_build_runner_command_template_ignores_defaults():
|
|
96
|
+
# Even though runner starts with "agy", template mode should not
|
|
97
|
+
# inject --dangerously-skip-permissions etc.
|
|
98
|
+
cmd = runner.build_runner_command(
|
|
99
|
+
"agy --custom {{prompt}}", "do stuff", yolo=True
|
|
100
|
+
)
|
|
101
|
+
assert "--dangerously-skip-permissions" not in cmd
|
|
102
|
+
assert cmd == ["agy", "--custom", "do stuff"]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_build_runner_command_template_prompt_in_flag_value():
|
|
106
|
+
cmd = runner.build_runner_command(
|
|
107
|
+
"my-tool --msg={{prompt}} --verbose", "hi there", yolo=True
|
|
108
|
+
)
|
|
109
|
+
assert cmd == ["my-tool", "--msg=hi there", "--verbose"]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_pretty_quote():
|
|
113
|
+
# Test fallback to shlex
|
|
114
|
+
assert runner._pretty_quote("simple") == "simple"
|
|
115
|
+
assert runner._pretty_quote("has space") == "'has space'"
|
|
116
|
+
|
|
117
|
+
# Test readable double quotes for single quotes
|
|
118
|
+
assert (
|
|
119
|
+
runner._pretty_quote("has 'single' quotes") == "\"has 'single' quotes\""
|
|
120
|
+
)
|
|
121
|
+
assert runner._pretty_quote("You are 'Lemming'") == "\"You are 'Lemming'\""
|
|
122
|
+
|
|
123
|
+
# Test string with double quotes (should fall back to single quotes)
|
|
124
|
+
assert (
|
|
125
|
+
runner._pretty_quote('has "double" quotes') == "'has \"double\" quotes'"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Test escaping specials inside double quotes
|
|
129
|
+
assert (
|
|
130
|
+
runner._pretty_quote("has 'single' and \"double\" quotes")
|
|
131
|
+
== '"has \'single\' and \\"double\\" quotes"'
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Test exclamation mark fallback
|
|
135
|
+
assert runner._pretty_quote("Hello!") == "'Hello!'"
|
|
136
|
+
|
|
137
|
+
assert runner._pretty_quote("has 'single' and !") == (
|
|
138
|
+
"'has '\"'\"'single'\"'\"' and !'"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Test idempotency (should NOT compound quotes)
|
|
142
|
+
q = runner._pretty_quote("it's!")
|
|
143
|
+
assert q == "'it'\"'\"'s!'"
|
|
144
|
+
qq = runner._pretty_quote(q)
|
|
145
|
+
assert qq == q
|
|
146
|
+
qqq = runner._pretty_quote(qq)
|
|
147
|
+
assert qqq == q
|
|
148
|
+
|
|
149
|
+
# Test idempotent single quotes (should NOT compound to multiple escapes)
|
|
150
|
+
q_s = runner._pretty_quote("it's")
|
|
151
|
+
assert q_s == '"it\'s"'
|
|
152
|
+
qq_s = runner._pretty_quote(q_s)
|
|
153
|
+
assert qq_s == q_s
|
|
154
|
+
|
|
155
|
+
# Test complex shell-quoted strings
|
|
156
|
+
already_quoted = "'path with space' and \"double'quotes\""
|
|
157
|
+
# This is NOT a single shell word, so it won't be unquoted.
|
|
158
|
+
# But it will be double-quoted correctly.
|
|
159
|
+
q_complex = runner._pretty_quote(already_quoted)
|
|
160
|
+
assert q_complex.startswith('"')
|
|
161
|
+
assert q_complex.endswith('"')
|
|
162
|
+
assert '\\"double\'quotes\\"' in q_complex
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_shlex_join_pretty():
|
|
166
|
+
cmd = [
|
|
167
|
+
"example-cli",
|
|
168
|
+
"--dangerously-skip-permissions",
|
|
169
|
+
"--print",
|
|
170
|
+
"You are 'Lemming'",
|
|
171
|
+
]
|
|
172
|
+
joined = runner._shlex_join_pretty(cmd)
|
|
173
|
+
assert (
|
|
174
|
+
joined == "example-cli --dangerously-skip-permissions --print"
|
|
175
|
+
" \"You are 'Lemming'\""
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Test truncation
|
|
179
|
+
long_arg = "a" * 300
|
|
180
|
+
joined_truncated = runner._shlex_join_pretty(["cli", long_arg], max_len=100)
|
|
181
|
+
assert "a" * 100 in joined_truncated
|
|
182
|
+
assert "... [truncated]" in joined_truncated
|
|
183
|
+
assert len(joined_truncated) < 150
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_run_with_heartbeat_truncation_only_affects_log(tmp_path):
|
|
187
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
188
|
+
task_id = "test_task"
|
|
189
|
+
log_file = paths.get_log_file(tasks_file, task_id)
|
|
190
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
|
|
192
|
+
# Use a long command that would be truncated in logs
|
|
193
|
+
long_arg = "a" * 300
|
|
194
|
+
cmd = ["echo", long_arg]
|
|
195
|
+
|
|
196
|
+
mock_process = unittest.mock.MagicMock()
|
|
197
|
+
mock_process.returncode = 0
|
|
198
|
+
mock_process.stdout = None
|
|
199
|
+
mock_process.poll.return_value = 0
|
|
200
|
+
|
|
201
|
+
with unittest.mock.patch(
|
|
202
|
+
"subprocess.Popen", return_value=mock_process
|
|
203
|
+
) as mock_popen:
|
|
204
|
+
runner.run_with_heartbeat(cmd, tasks_file, task_id, verbose=False)
|
|
205
|
+
|
|
206
|
+
# Verify Popen received the original untruncated cmd
|
|
207
|
+
mock_popen.assert_called_once()
|
|
208
|
+
called_cmd = mock_popen.call_args[0][0]
|
|
209
|
+
assert called_cmd == cmd
|
|
210
|
+
assert len(called_cmd[1]) == 300
|
|
211
|
+
|
|
212
|
+
# Verify log file contains the truncated command
|
|
213
|
+
content = log_file.read_text()
|
|
214
|
+
assert "Command: echo " in content
|
|
215
|
+
assert "a" * 200 in content
|
|
216
|
+
assert "... [truncated]" in content
|
|
217
|
+
assert "a" * 201 not in content
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_run_with_heartbeat_log_header(tmp_path):
|
|
221
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
222
|
+
task_id = "test_task"
|
|
223
|
+
log_file = paths.get_log_file(tasks_file, task_id)
|
|
224
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
225
|
+
|
|
226
|
+
# Use a command that exits quickly
|
|
227
|
+
cmd = ["true"]
|
|
228
|
+
|
|
229
|
+
# 1. Run with a header
|
|
230
|
+
runner.run_with_heartbeat(
|
|
231
|
+
cmd, tasks_file, task_id, verbose=False, header="Hook: roadmap"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
content = log_file.read_text()
|
|
235
|
+
assert "--- Attempt started at" in content
|
|
236
|
+
assert "HOOK: ROADMAP started at" in content
|
|
237
|
+
assert "=" * 80 in content
|
|
238
|
+
|
|
239
|
+
# 2. Run without a header (it should still have the attempt marker)
|
|
240
|
+
task_id_2 = "test_task_2"
|
|
241
|
+
log_file_2 = paths.get_log_file(tasks_file, task_id_2)
|
|
242
|
+
runner.run_with_heartbeat(
|
|
243
|
+
cmd, tasks_file, task_id_2, verbose=False, header=None
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
content_2 = log_file_2.read_text()
|
|
247
|
+
assert "--- Attempt started at" in content_2
|
|
248
|
+
assert "started at" not in content_2.replace("Attempt started at", "")
|
|
249
|
+
assert "=" * 80 not in content_2
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_run_with_heartbeat_interruption_cleanup(tmp_path):
|
|
253
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
254
|
+
task_id = "test_task"
|
|
255
|
+
|
|
256
|
+
# 1. Setup a dummy Roadmap
|
|
257
|
+
roadmap = tasks.Roadmap(tasks=[tasks.Task(id=task_id, description="test")])
|
|
258
|
+
tasks.save_tasks(tasks_file, roadmap)
|
|
259
|
+
|
|
260
|
+
# 2. Mock subprocess.Popen and related functions
|
|
261
|
+
mock_process = unittest.mock.MagicMock()
|
|
262
|
+
mock_process.pid = 12345
|
|
263
|
+
mock_process.stdout = None
|
|
264
|
+
# We want process.wait() to raise a KeyboardInterrupt (BaseException)
|
|
265
|
+
mock_process.wait.side_effect = KeyboardInterrupt()
|
|
266
|
+
|
|
267
|
+
with (
|
|
268
|
+
unittest.mock.patch("subprocess.Popen", return_value=mock_process),
|
|
269
|
+
unittest.mock.patch("os.killpg") as mock_killpg,
|
|
270
|
+
unittest.mock.patch("os.getpgid", return_value=54321),
|
|
271
|
+
):
|
|
272
|
+
# 3. Call run_with_heartbeat and expect it to re-raise KeyboardInterrupt
|
|
273
|
+
try:
|
|
274
|
+
runner.run_with_heartbeat(
|
|
275
|
+
["long-running-cmd"], tasks_file, task_id, verbose=False
|
|
276
|
+
)
|
|
277
|
+
except KeyboardInterrupt:
|
|
278
|
+
pass
|
|
279
|
+
else:
|
|
280
|
+
assert False, "KeyboardInterrupt was not raised"
|
|
281
|
+
|
|
282
|
+
# 4. Verify cleanup was attempted
|
|
283
|
+
mock_killpg.assert_called_once_with(54321, signal.SIGTERM)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def test_returncode_timeout_constant():
|
|
287
|
+
assert runner.RETURNCODE_TIMEOUT == -14
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_kill_process_tree_killpg():
|
|
291
|
+
"""Verifies _kill_process_tree uses killpg first."""
|
|
292
|
+
process = unittest.mock.MagicMock(spec=subprocess.Popen)
|
|
293
|
+
process.pid = 12345
|
|
294
|
+
|
|
295
|
+
with (
|
|
296
|
+
unittest.mock.patch("os.killpg") as mock_killpg,
|
|
297
|
+
unittest.mock.patch("os.getpgid", return_value=54321),
|
|
298
|
+
):
|
|
299
|
+
runner._kill_process_tree(process)
|
|
300
|
+
mock_killpg.assert_called_once_with(54321, signal.SIGTERM)
|
|
301
|
+
process.kill.assert_not_called()
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def test_kill_process_tree_fallback():
|
|
305
|
+
"""Verifies _kill_process_tree falls back to process.kill on OSError."""
|
|
306
|
+
process = unittest.mock.MagicMock(spec=subprocess.Popen)
|
|
307
|
+
process.pid = 12345
|
|
308
|
+
|
|
309
|
+
with (
|
|
310
|
+
unittest.mock.patch("os.killpg", side_effect=OSError),
|
|
311
|
+
unittest.mock.patch("os.getpgid", return_value=54321),
|
|
312
|
+
):
|
|
313
|
+
runner._kill_process_tree(process)
|
|
314
|
+
process.kill.assert_called_once()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_run_with_heartbeat_timeout(tmp_path):
|
|
318
|
+
"""Verifies run_with_heartbeat kills and records progress on timeout."""
|
|
319
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
320
|
+
task_id = "timeout_task"
|
|
321
|
+
|
|
322
|
+
# Setup a task so heartbeat updates work
|
|
323
|
+
roadmap = tasks.Roadmap(
|
|
324
|
+
tasks=[tasks.Task(id=task_id, description="test timeout")]
|
|
325
|
+
)
|
|
326
|
+
tasks.save_tasks(tasks_file, roadmap)
|
|
327
|
+
tasks.mark_task_in_progress(tasks_file, task_id)
|
|
328
|
+
|
|
329
|
+
# Use a 1-minute time limit. Mock time.monotonic to simulate elapsed
|
|
330
|
+
# time so the heartbeat loop detects the timeout immediately without
|
|
331
|
+
# waiting 60 real seconds.
|
|
332
|
+
real_monotonic = time.monotonic
|
|
333
|
+
call_count = 0
|
|
334
|
+
|
|
335
|
+
def fast_monotonic():
|
|
336
|
+
nonlocal call_count
|
|
337
|
+
call_count += 1
|
|
338
|
+
# After the first call (start_time), jump 2 minutes ahead
|
|
339
|
+
if call_count > 1:
|
|
340
|
+
return real_monotonic() + 120
|
|
341
|
+
return real_monotonic()
|
|
342
|
+
|
|
343
|
+
with unittest.mock.patch("time.monotonic", side_effect=fast_monotonic):
|
|
344
|
+
returncode, stdout, stderr = runner.run_with_heartbeat(
|
|
345
|
+
["sleep", "60"],
|
|
346
|
+
tasks_file,
|
|
347
|
+
task_id,
|
|
348
|
+
verbose=False,
|
|
349
|
+
time_limit=1,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
assert returncode == runner.RETURNCODE_TIMEOUT
|
|
353
|
+
|
|
354
|
+
# Verify the timeout progress was recorded
|
|
355
|
+
data = tasks.load_tasks(tasks_file)
|
|
356
|
+
task = next(t for t in data.tasks if t.id == task_id)
|
|
357
|
+
assert any("time limit" in o for o in task.progress)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def test_run_with_heartbeat_no_timeout(tmp_path):
|
|
361
|
+
"""Verifies that time_limit=0 does not enforce any timeout."""
|
|
362
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
363
|
+
task_id = "no_timeout"
|
|
364
|
+
|
|
365
|
+
roadmap = tasks.Roadmap(
|
|
366
|
+
tasks=[tasks.Task(id=task_id, description="test no timeout")]
|
|
367
|
+
)
|
|
368
|
+
tasks.save_tasks(tasks_file, roadmap)
|
|
369
|
+
|
|
370
|
+
returncode, _, _ = runner.run_with_heartbeat(
|
|
371
|
+
["true"],
|
|
372
|
+
tasks_file,
|
|
373
|
+
task_id,
|
|
374
|
+
verbose=False,
|
|
375
|
+
time_limit=0,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
assert returncode == 0
|