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/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