galangal-orchestrate 0.13.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 (79) hide show
  1. galangal/__init__.py +36 -0
  2. galangal/__main__.py +6 -0
  3. galangal/ai/__init__.py +167 -0
  4. galangal/ai/base.py +159 -0
  5. galangal/ai/claude.py +352 -0
  6. galangal/ai/codex.py +370 -0
  7. galangal/ai/gemini.py +43 -0
  8. galangal/ai/subprocess.py +254 -0
  9. galangal/cli.py +371 -0
  10. galangal/commands/__init__.py +27 -0
  11. galangal/commands/complete.py +367 -0
  12. galangal/commands/github.py +355 -0
  13. galangal/commands/init.py +177 -0
  14. galangal/commands/init_wizard.py +762 -0
  15. galangal/commands/list.py +20 -0
  16. galangal/commands/pause.py +34 -0
  17. galangal/commands/prompts.py +89 -0
  18. galangal/commands/reset.py +41 -0
  19. galangal/commands/resume.py +30 -0
  20. galangal/commands/skip.py +62 -0
  21. galangal/commands/start.py +530 -0
  22. galangal/commands/status.py +44 -0
  23. galangal/commands/switch.py +28 -0
  24. galangal/config/__init__.py +15 -0
  25. galangal/config/defaults.py +183 -0
  26. galangal/config/loader.py +163 -0
  27. galangal/config/schema.py +330 -0
  28. galangal/core/__init__.py +33 -0
  29. galangal/core/artifacts.py +136 -0
  30. galangal/core/state.py +1097 -0
  31. galangal/core/tasks.py +454 -0
  32. galangal/core/utils.py +116 -0
  33. galangal/core/workflow/__init__.py +68 -0
  34. galangal/core/workflow/core.py +789 -0
  35. galangal/core/workflow/engine.py +781 -0
  36. galangal/core/workflow/pause.py +35 -0
  37. galangal/core/workflow/tui_runner.py +1322 -0
  38. galangal/exceptions.py +36 -0
  39. galangal/github/__init__.py +31 -0
  40. galangal/github/client.py +427 -0
  41. galangal/github/images.py +324 -0
  42. galangal/github/issues.py +298 -0
  43. galangal/logging.py +364 -0
  44. galangal/prompts/__init__.py +5 -0
  45. galangal/prompts/builder.py +527 -0
  46. galangal/prompts/defaults/benchmark.md +34 -0
  47. galangal/prompts/defaults/contract.md +35 -0
  48. galangal/prompts/defaults/design.md +54 -0
  49. galangal/prompts/defaults/dev.md +89 -0
  50. galangal/prompts/defaults/docs.md +104 -0
  51. galangal/prompts/defaults/migration.md +59 -0
  52. galangal/prompts/defaults/pm.md +110 -0
  53. galangal/prompts/defaults/pm_questions.md +53 -0
  54. galangal/prompts/defaults/preflight.md +32 -0
  55. galangal/prompts/defaults/qa.md +65 -0
  56. galangal/prompts/defaults/review.md +90 -0
  57. galangal/prompts/defaults/review_codex.md +99 -0
  58. galangal/prompts/defaults/security.md +84 -0
  59. galangal/prompts/defaults/test.md +91 -0
  60. galangal/results.py +176 -0
  61. galangal/ui/__init__.py +5 -0
  62. galangal/ui/console.py +126 -0
  63. galangal/ui/tui/__init__.py +56 -0
  64. galangal/ui/tui/adapters.py +168 -0
  65. galangal/ui/tui/app.py +902 -0
  66. galangal/ui/tui/entry.py +24 -0
  67. galangal/ui/tui/mixins.py +196 -0
  68. galangal/ui/tui/modals.py +339 -0
  69. galangal/ui/tui/styles/app.tcss +86 -0
  70. galangal/ui/tui/styles/modals.tcss +197 -0
  71. galangal/ui/tui/types.py +107 -0
  72. galangal/ui/tui/widgets.py +263 -0
  73. galangal/validation/__init__.py +5 -0
  74. galangal/validation/runner.py +1072 -0
  75. galangal_orchestrate-0.13.0.dist-info/METADATA +985 -0
  76. galangal_orchestrate-0.13.0.dist-info/RECORD +79 -0
  77. galangal_orchestrate-0.13.0.dist-info/WHEEL +4 -0
  78. galangal_orchestrate-0.13.0.dist-info/entry_points.txt +2 -0
  79. galangal_orchestrate-0.13.0.dist-info/licenses/LICENSE +674 -0
galangal/ai/codex.py ADDED
@@ -0,0 +1,370 @@
1
+ """
2
+ Codex CLI backend implementation for read-only code review.
3
+
4
+ Uses OpenAI's Codex in non-interactive mode with structured JSON output.
5
+ See: https://developers.openai.com/codex/noninteractive
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import subprocess
13
+ import time
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from galangal.ai.base import AIBackend, PauseCheck
17
+ from galangal.ai.subprocess import SubprocessRunner
18
+ from galangal.config.loader import get_project_root
19
+ from galangal.results import StageResult
20
+
21
+ if TYPE_CHECKING:
22
+ from galangal.ui.tui import StageUI
23
+
24
+
25
+ def _build_output_schema(stage: str | None) -> dict[str, Any]:
26
+ """
27
+ Build stage-specific JSON output schema.
28
+
29
+ Derives artifact field names and decision values from STAGE_METADATA.
30
+ Falls back to generic review schema if stage not found.
31
+
32
+ Args:
33
+ stage: Stage name (e.g., "QA", "SECURITY", "REVIEW")
34
+
35
+ Returns:
36
+ JSON schema dict for structured output
37
+ """
38
+ from galangal.core.state import Stage, get_decision_values
39
+
40
+ # Defaults for unknown or unspecified stages
41
+ notes_field = "review_notes"
42
+ notes_description = "Full review findings in markdown format"
43
+ decision_values = ["APPROVE", "REQUEST_CHANGES"]
44
+
45
+ if stage:
46
+ try:
47
+ stage_enum = Stage.from_str(stage)
48
+ metadata = stage_enum.metadata
49
+
50
+ # Derive notes field from produces_artifacts
51
+ if metadata.produces_artifacts:
52
+ artifact_name = metadata.produces_artifacts[0]
53
+ # Convert "QA_REPORT.md" -> "qa_report"
54
+ notes_field = artifact_name.lower().replace(".md", "")
55
+ notes_description = f"{metadata.display_name} findings in markdown format"
56
+
57
+ # Get decision values from metadata
58
+ values = get_decision_values(stage_enum)
59
+ if values:
60
+ decision_values = values
61
+
62
+ except (ValueError, AttributeError):
63
+ # Stage not found or invalid, use defaults
64
+ pass
65
+
66
+ return {
67
+ "type": "object",
68
+ "properties": {
69
+ notes_field: {
70
+ "type": "string",
71
+ "description": notes_description,
72
+ },
73
+ "decision": {
74
+ "type": "string",
75
+ "enum": decision_values,
76
+ "description": "Stage decision",
77
+ },
78
+ "issues": {
79
+ "type": "array",
80
+ "description": "List of specific issues found",
81
+ "items": {
82
+ "type": "object",
83
+ "properties": {
84
+ "severity": {
85
+ "type": "string",
86
+ "enum": ["critical", "major", "minor", "suggestion"],
87
+ },
88
+ "file": {"type": "string"},
89
+ "line": {"type": "integer"},
90
+ "description": {"type": "string"},
91
+ },
92
+ "required": ["severity", "file", "line", "description"],
93
+ "additionalProperties": False,
94
+ },
95
+ },
96
+ },
97
+ "required": [notes_field, "decision", "issues"],
98
+ "additionalProperties": False,
99
+ }
100
+
101
+
102
+ class CodexBackend(AIBackend):
103
+ """
104
+ Codex CLI backend for read-only code review.
105
+
106
+ Key characteristics:
107
+ - Runs in read-only sandbox by default (cannot write files)
108
+ - Uses --output-schema for structured JSON output
109
+ - Artifacts must be written by post-processing the output
110
+ """
111
+
112
+ # Default command and args when no config provided
113
+ DEFAULT_COMMAND = "codex"
114
+ DEFAULT_ARGS = [
115
+ "exec",
116
+ "--full-auto",
117
+ "--output-schema",
118
+ "{schema_file}",
119
+ "-o",
120
+ "{output_file}",
121
+ ]
122
+
123
+ @property
124
+ def name(self) -> str:
125
+ return "codex"
126
+
127
+ def _build_command(
128
+ self,
129
+ prompt_file: str,
130
+ schema_file: str,
131
+ output_file: str,
132
+ ) -> str:
133
+ """
134
+ Build the shell command to invoke Codex.
135
+
136
+ Uses config.command and config.args if available, otherwise falls back
137
+ to hard-coded defaults for backwards compatibility.
138
+
139
+ Args:
140
+ prompt_file: Path to temp file containing the prompt
141
+ schema_file: Path to JSON schema file
142
+ output_file: Path for structured output
143
+
144
+ Returns:
145
+ Shell command string ready for subprocess
146
+ """
147
+ if self._config:
148
+ command = self._config.command
149
+ args = self._substitute_placeholders(
150
+ self._config.args,
151
+ schema_file=schema_file,
152
+ output_file=output_file,
153
+ )
154
+ else:
155
+ # Backwards compatibility: use defaults
156
+ command = self.DEFAULT_COMMAND
157
+ args = self._substitute_placeholders(
158
+ self.DEFAULT_ARGS,
159
+ schema_file=schema_file,
160
+ output_file=output_file,
161
+ )
162
+
163
+ args_str = " ".join(f"'{a}'" if " " in a else a for a in args)
164
+ return f"cat '{prompt_file}' | {command} {args_str}"
165
+
166
+ def invoke(
167
+ self,
168
+ prompt: str,
169
+ timeout: int = 14400,
170
+ max_turns: int = 200,
171
+ ui: StageUI | None = None,
172
+ pause_check: PauseCheck | None = None,
173
+ stage: str | None = None,
174
+ log_file: str | None = None,
175
+ ) -> StageResult:
176
+ """
177
+ Invoke Codex in non-interactive read-only mode.
178
+
179
+ Uses --output-schema to enforce structured JSON output with:
180
+ - Stage-specific notes field (qa_report, security_checklist, review_notes)
181
+ - decision: Stage-appropriate values (PASS/FAIL, APPROVED/REJECTED, etc.)
182
+ - issues: Array of specific problems found
183
+
184
+ Args:
185
+ prompt: The full prompt to send
186
+ timeout: Maximum time in seconds
187
+ max_turns: Maximum conversation turns (unused for Codex)
188
+ ui: Optional TUI for progress display
189
+ pause_check: Optional callback for pause detection
190
+ stage: Stage name for schema customization (e.g., "QA", "SECURITY")
191
+ log_file: Optional path to log file (unused for Codex read-only)
192
+
193
+ Returns:
194
+ StageResult with structured JSON in the output field
195
+ """
196
+ # Track timing for activity updates
197
+ start_time = time.time()
198
+ last_activity_time = start_time
199
+
200
+ def on_output(line: str) -> None:
201
+ """Process each output line."""
202
+ nonlocal last_activity_time
203
+ line = line.strip()
204
+ # Show meaningful output lines, skip raw JSON
205
+ if line and not line.startswith("{"):
206
+ if ui:
207
+ ui.add_activity(f"codex: {line[:80]}", "💬")
208
+ last_activity_time = time.time()
209
+
210
+ def on_idle(elapsed: float) -> None:
211
+ """Update status periodically."""
212
+ nonlocal last_activity_time
213
+ if not ui:
214
+ return
215
+
216
+ # Update status with elapsed time
217
+ minutes = int(elapsed // 60)
218
+ seconds = int(elapsed % 60)
219
+ time_str = f"{minutes}m {seconds}s" if minutes > 0 else f"{seconds}s"
220
+ ui.set_status("running", f"Codex reviewing code ({time_str})")
221
+
222
+ # Add activity update if no output for 30 seconds
223
+ current_time = time.time()
224
+ if current_time - last_activity_time >= 30.0:
225
+ if minutes > 0:
226
+ ui.add_activity(f"Still reviewing... ({minutes}m elapsed)", "⏳")
227
+ else:
228
+ ui.add_activity("Still reviewing...", "⏳")
229
+ last_activity_time = current_time
230
+
231
+ try:
232
+ # Create temp files for prompt, schema, and output
233
+ output_schema = _build_output_schema(stage)
234
+ schema_content = json.dumps(output_schema)
235
+ with (
236
+ self._temp_file(prompt, suffix=".txt") as prompt_file,
237
+ self._temp_file(schema_content, suffix=".json") as schema_file,
238
+ self._temp_file(suffix=".json") as output_file,
239
+ ):
240
+ if ui:
241
+ ui.set_status("starting", "initializing Codex")
242
+
243
+ shell_cmd = self._build_command(prompt_file, schema_file, output_file)
244
+
245
+ if ui:
246
+ ui.set_status("running", "Codex reviewing code")
247
+ ui.add_activity("Codex code review started", "🔍")
248
+
249
+ runner = SubprocessRunner(
250
+ command=shell_cmd,
251
+ timeout=timeout,
252
+ pause_check=pause_check,
253
+ ui=ui,
254
+ on_output=on_output,
255
+ on_idle=on_idle,
256
+ idle_interval=5.0,
257
+ poll_interval_active=0.05,
258
+ poll_interval_idle=0.5,
259
+ )
260
+
261
+ result = runner.run()
262
+
263
+ if result.paused:
264
+ if ui:
265
+ ui.finish(success=False)
266
+ return StageResult.paused()
267
+
268
+ if result.timed_out:
269
+ return StageResult.timeout(result.timeout_seconds or timeout)
270
+
271
+ # Process completed
272
+ if result.exit_code != 0:
273
+ if ui:
274
+ ui.add_activity(f"Codex failed (exit {result.exit_code})", "❌")
275
+ ui.finish(success=False)
276
+ return StageResult.error(
277
+ message=f"Codex failed (exit {result.exit_code})",
278
+ output=result.output,
279
+ )
280
+
281
+ # Read the structured output
282
+ if not os.path.exists(output_file):
283
+ if ui:
284
+ ui.add_activity("No output file generated", "❌")
285
+ ui.finish(success=False)
286
+ debug_output = f"Expected output at: {output_file}\nOutput:\n{result.output}"
287
+ return StageResult.error(
288
+ message="Codex did not produce output file. Check if --output-schema is supported.",
289
+ output=debug_output,
290
+ )
291
+
292
+ with open(output_file, encoding="utf-8") as f:
293
+ output_content = f.read()
294
+
295
+ # Validate JSON structure
296
+ try:
297
+ output_data = json.loads(output_content)
298
+ decision = output_data.get("decision", "")
299
+
300
+ if ui:
301
+ issues_count = len(output_data.get("issues", []))
302
+ elapsed = time.time() - start_time
303
+ minutes = int(elapsed // 60)
304
+ seconds = int(elapsed % 60)
305
+ time_str = f"{minutes}m {seconds}s" if minutes > 0 else f"{seconds}s"
306
+
307
+ if decision == "APPROVE":
308
+ ui.add_activity(
309
+ f"Review complete: APPROVED ({issues_count} suggestions) in {time_str}",
310
+ "✅",
311
+ )
312
+ else:
313
+ ui.add_activity(
314
+ f"Review complete: {issues_count} issues found in {time_str}",
315
+ "⚠️",
316
+ )
317
+ ui.finish(success=True)
318
+
319
+ return StageResult.create_success(
320
+ message=f"Codex review complete: {decision}",
321
+ output=output_content,
322
+ )
323
+
324
+ except json.JSONDecodeError as e:
325
+ if ui:
326
+ ui.add_activity("Invalid JSON output", "❌")
327
+ ui.finish(success=False)
328
+ return StageResult.error(
329
+ message=f"Codex output is not valid JSON: {e}",
330
+ output=output_content,
331
+ )
332
+
333
+ except Exception as e:
334
+ if ui:
335
+ ui.finish(success=False)
336
+ return StageResult.error(f"Codex invocation error: {e}")
337
+
338
+ def generate_text(self, prompt: str, timeout: int = 30) -> str:
339
+ """
340
+ Simple text generation using Codex.
341
+
342
+ Note: For simple text generation, we use codex exec without
343
+ structured output schema.
344
+ """
345
+ try:
346
+ with (
347
+ self._temp_file(prompt, suffix=".txt") as prompt_file,
348
+ self._temp_file(suffix=".txt") as output_file,
349
+ ):
350
+ # Use config command or default
351
+ command = self._config.command if self._config else self.DEFAULT_COMMAND
352
+ shell_cmd = f"cat '{prompt_file}' | {command} exec -o '{output_file}'"
353
+
354
+ result = subprocess.run(
355
+ shell_cmd,
356
+ shell=True,
357
+ cwd=get_project_root(),
358
+ capture_output=True,
359
+ text=True,
360
+ timeout=timeout,
361
+ )
362
+
363
+ if result.returncode == 0 and os.path.exists(output_file):
364
+ with open(output_file, encoding="utf-8") as f:
365
+ return f.read().strip()
366
+
367
+ except (subprocess.TimeoutExpired, Exception):
368
+ pass
369
+
370
+ return ""
galangal/ai/gemini.py ADDED
@@ -0,0 +1,43 @@
1
+ """
2
+ Gemini backend implementation (stub for future use).
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import TYPE_CHECKING
8
+
9
+ from galangal.ai.base import AIBackend, PauseCheck
10
+ from galangal.results import StageResult
11
+
12
+ if TYPE_CHECKING:
13
+ from galangal.ui.tui import StageUI
14
+
15
+
16
+ class GeminiBackend(AIBackend):
17
+ """
18
+ Gemini backend (stub implementation).
19
+
20
+ TODO: Implement when Gemini CLI or API support is added.
21
+ """
22
+
23
+ @property
24
+ def name(self) -> str:
25
+ return "gemini"
26
+
27
+ def invoke(
28
+ self,
29
+ prompt: str,
30
+ timeout: int = 14400,
31
+ max_turns: int = 200,
32
+ ui: StageUI | None = None,
33
+ pause_check: PauseCheck | None = None,
34
+ stage: str | None = None,
35
+ ) -> StageResult:
36
+ """Invoke Gemini with a prompt."""
37
+ # TODO: Implement Gemini invocation
38
+ return StageResult.error("Gemini backend not yet implemented")
39
+
40
+ def generate_text(self, prompt: str, timeout: int = 30) -> str:
41
+ """Simple text generation with Gemini."""
42
+ # TODO: Implement Gemini text generation
43
+ return ""
@@ -0,0 +1,254 @@
1
+ """
2
+ Subprocess runner with pause/timeout handling for AI backends.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import select
8
+ import subprocess
9
+ import time
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass
12
+ from enum import Enum
13
+ from typing import TYPE_CHECKING
14
+
15
+ from galangal.config.loader import get_project_root
16
+
17
+ if TYPE_CHECKING:
18
+ from galangal.ui.tui import StageUI
19
+
20
+
21
+ class RunOutcome(Enum):
22
+ """Outcome of a subprocess run."""
23
+
24
+ COMPLETED = "completed"
25
+ PAUSED = "paused"
26
+ TIMEOUT = "timeout"
27
+
28
+
29
+ @dataclass
30
+ class RunResult:
31
+ """Result of running a subprocess."""
32
+
33
+ outcome: RunOutcome
34
+ exit_code: int | None
35
+ output: str
36
+ timeout_seconds: int | None = None
37
+
38
+ @property
39
+ def completed(self) -> bool:
40
+ return self.outcome == RunOutcome.COMPLETED
41
+
42
+ @property
43
+ def paused(self) -> bool:
44
+ return self.outcome == RunOutcome.PAUSED
45
+
46
+ @property
47
+ def timed_out(self) -> bool:
48
+ return self.outcome == RunOutcome.TIMEOUT
49
+
50
+
51
+ # Type aliases
52
+ PauseCheck = Callable[[], bool]
53
+ OutputCallback = Callable[[str], None]
54
+ IdleCallback = Callable[[float], None] # Called with elapsed seconds
55
+
56
+
57
+ class SubprocessRunner:
58
+ """
59
+ Manages subprocess lifecycle with pause/timeout support.
60
+
61
+ Consolidates the common subprocess handling pattern used by AI backends:
62
+ - Non-blocking output reading with select()
63
+ - Pause request handling (graceful termination)
64
+ - Timeout handling
65
+ - Periodic idle callbacks for status updates
66
+
67
+ Usage:
68
+ runner = SubprocessRunner(
69
+ command="cat prompt.txt | claude --verbose",
70
+ timeout=3600,
71
+ pause_check=lambda: user_requested_pause,
72
+ on_output=lambda line: process_line(line),
73
+ on_idle=lambda elapsed: update_status(elapsed),
74
+ )
75
+ result = runner.run()
76
+ if result.completed:
77
+ # Process result.output
78
+ """
79
+
80
+ def __init__(
81
+ self,
82
+ command: str,
83
+ timeout: int = 14400,
84
+ pause_check: PauseCheck | None = None,
85
+ ui: StageUI | None = None,
86
+ on_output: OutputCallback | None = None,
87
+ on_idle: IdleCallback | None = None,
88
+ idle_interval: float = 3.0,
89
+ poll_interval_active: float = 0.05,
90
+ poll_interval_idle: float = 0.5,
91
+ ):
92
+ """
93
+ Initialize the subprocess runner.
94
+
95
+ Args:
96
+ command: Shell command to execute
97
+ timeout: Maximum runtime in seconds
98
+ pause_check: Callback returning True if pause requested
99
+ ui: Optional TUI for basic status updates
100
+ on_output: Callback for each output line
101
+ on_idle: Callback when idle (no output), receives elapsed seconds
102
+ idle_interval: Seconds between idle callbacks
103
+ poll_interval_active: Sleep between polls when receiving output
104
+ poll_interval_idle: Sleep between polls when idle
105
+ """
106
+ self.command = command
107
+ self.timeout = timeout
108
+ self.pause_check = pause_check
109
+ self.ui = ui
110
+ self.on_output = on_output
111
+ self.on_idle = on_idle
112
+ self.idle_interval = idle_interval
113
+ self.poll_interval_active = poll_interval_active
114
+ self.poll_interval_idle = poll_interval_idle
115
+
116
+ def run(self) -> RunResult:
117
+ """
118
+ Run the subprocess with pause/timeout handling.
119
+
120
+ Returns:
121
+ RunResult with outcome, exit code, and captured output
122
+ """
123
+ process = subprocess.Popen(
124
+ self.command,
125
+ shell=True,
126
+ cwd=get_project_root(),
127
+ stdout=subprocess.PIPE,
128
+ stderr=subprocess.STDOUT,
129
+ text=True,
130
+ )
131
+
132
+ output_lines: list[str] = []
133
+ start_time = time.time()
134
+ last_idle_callback = start_time
135
+
136
+ try:
137
+ while True:
138
+ retcode = process.poll()
139
+
140
+ # Read available output (non-blocking)
141
+ had_output = self._read_output(process, output_lines)
142
+
143
+ # Update last idle callback time if we had output
144
+ if had_output:
145
+ last_idle_callback = time.time()
146
+
147
+ # Process completed
148
+ if retcode is not None:
149
+ break
150
+
151
+ # Check for pause request
152
+ if self.pause_check and self.pause_check():
153
+ self._terminate_gracefully(process)
154
+ if self.ui:
155
+ self.ui.add_activity("Paused by user request", "⏸️")
156
+ return RunResult(
157
+ outcome=RunOutcome.PAUSED,
158
+ exit_code=None,
159
+ output="".join(output_lines),
160
+ )
161
+
162
+ # Check for timeout
163
+ elapsed = time.time() - start_time
164
+ if elapsed > self.timeout:
165
+ process.kill()
166
+ if self.ui:
167
+ self.ui.add_activity(f"Timeout after {self.timeout}s", "❌")
168
+ return RunResult(
169
+ outcome=RunOutcome.TIMEOUT,
170
+ exit_code=None,
171
+ output="".join(output_lines),
172
+ timeout_seconds=self.timeout,
173
+ )
174
+
175
+ # Idle callback for status updates
176
+ current_time = time.time()
177
+ if self.on_idle and current_time - last_idle_callback >= self.idle_interval:
178
+ self.on_idle(elapsed)
179
+ last_idle_callback = current_time
180
+
181
+ # Adaptive sleep
182
+ time.sleep(self.poll_interval_active if had_output else self.poll_interval_idle)
183
+
184
+ # Capture any remaining output
185
+ remaining = self._capture_remaining(process)
186
+ if remaining:
187
+ output_lines.append(remaining)
188
+
189
+ return RunResult(
190
+ outcome=RunOutcome.COMPLETED,
191
+ exit_code=process.returncode,
192
+ output="".join(output_lines),
193
+ )
194
+
195
+ except Exception:
196
+ # Ensure process is terminated on any error
197
+ try:
198
+ process.kill()
199
+ except Exception:
200
+ pass
201
+ raise
202
+
203
+ def _read_output(
204
+ self,
205
+ process: subprocess.Popen[str],
206
+ output_lines: list[str],
207
+ ) -> bool:
208
+ """
209
+ Read all available output lines (non-blocking).
210
+
211
+ Returns True if any output was read.
212
+ """
213
+ had_output = False
214
+
215
+ if not process.stdout:
216
+ return False
217
+
218
+ try:
219
+ while True:
220
+ ready, _, _ = select.select([process.stdout], [], [], 0)
221
+ if not ready:
222
+ break
223
+
224
+ line = process.stdout.readline()
225
+ if not line:
226
+ break
227
+
228
+ output_lines.append(line)
229
+ had_output = True
230
+
231
+ if self.on_output:
232
+ self.on_output(line)
233
+
234
+ except (ValueError, TypeError, OSError):
235
+ # select() may fail on non-selectable streams
236
+ pass
237
+
238
+ return had_output
239
+
240
+ def _terminate_gracefully(self, process: subprocess.Popen[str]) -> None:
241
+ """Terminate process gracefully, then force kill if needed."""
242
+ process.terminate()
243
+ try:
244
+ process.wait(timeout=5)
245
+ except subprocess.TimeoutExpired:
246
+ process.kill()
247
+
248
+ def _capture_remaining(self, process: subprocess.Popen[str]) -> str:
249
+ """Capture any remaining output after process completes."""
250
+ try:
251
+ remaining, _ = process.communicate(timeout=10)
252
+ return remaining or ""
253
+ except (OSError, ValueError, subprocess.TimeoutExpired):
254
+ return ""