pdd-cli 0.0.90__py3-none-any.whl → 0.0.118__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 (144) hide show
  1. pdd/__init__.py +38 -6
  2. pdd/agentic_bug.py +323 -0
  3. pdd/agentic_bug_orchestrator.py +497 -0
  4. pdd/agentic_change.py +231 -0
  5. pdd/agentic_change_orchestrator.py +526 -0
  6. pdd/agentic_common.py +521 -786
  7. pdd/agentic_e2e_fix.py +319 -0
  8. pdd/agentic_e2e_fix_orchestrator.py +426 -0
  9. pdd/agentic_fix.py +118 -3
  10. pdd/agentic_update.py +25 -8
  11. pdd/architecture_sync.py +565 -0
  12. pdd/auth_service.py +210 -0
  13. pdd/auto_deps_main.py +63 -53
  14. pdd/auto_include.py +185 -3
  15. pdd/auto_update.py +125 -47
  16. pdd/bug_main.py +195 -23
  17. pdd/cmd_test_main.py +345 -197
  18. pdd/code_generator.py +4 -2
  19. pdd/code_generator_main.py +118 -32
  20. pdd/commands/__init__.py +6 -0
  21. pdd/commands/analysis.py +87 -29
  22. pdd/commands/auth.py +309 -0
  23. pdd/commands/connect.py +290 -0
  24. pdd/commands/fix.py +136 -113
  25. pdd/commands/maintenance.py +3 -2
  26. pdd/commands/misc.py +8 -0
  27. pdd/commands/modify.py +190 -164
  28. pdd/commands/sessions.py +284 -0
  29. pdd/construct_paths.py +334 -32
  30. pdd/context_generator_main.py +167 -170
  31. pdd/continue_generation.py +6 -3
  32. pdd/core/__init__.py +33 -0
  33. pdd/core/cli.py +27 -3
  34. pdd/core/cloud.py +237 -0
  35. pdd/core/errors.py +4 -0
  36. pdd/core/remote_session.py +61 -0
  37. pdd/crash_main.py +219 -23
  38. pdd/data/llm_model.csv +4 -4
  39. pdd/docs/prompting_guide.md +864 -0
  40. pdd/docs/whitepaper_with_benchmarks/data_and_functions/benchmark_analysis.py +495 -0
  41. pdd/docs/whitepaper_with_benchmarks/data_and_functions/creation_compare.py +528 -0
  42. pdd/fix_code_loop.py +208 -34
  43. pdd/fix_code_module_errors.py +6 -2
  44. pdd/fix_error_loop.py +291 -38
  45. pdd/fix_main.py +204 -4
  46. pdd/fix_verification_errors_loop.py +235 -26
  47. pdd/fix_verification_main.py +269 -83
  48. pdd/frontend/dist/assets/index-B5DZHykP.css +1 -0
  49. pdd/frontend/dist/assets/index-DQ3wkeQ2.js +449 -0
  50. pdd/frontend/dist/index.html +376 -0
  51. pdd/frontend/dist/logo.svg +33 -0
  52. pdd/generate_output_paths.py +46 -5
  53. pdd/generate_test.py +212 -151
  54. pdd/get_comment.py +19 -44
  55. pdd/get_extension.py +8 -9
  56. pdd/get_jwt_token.py +309 -20
  57. pdd/get_language.py +8 -7
  58. pdd/get_run_command.py +7 -5
  59. pdd/insert_includes.py +2 -1
  60. pdd/llm_invoke.py +459 -95
  61. pdd/load_prompt_template.py +15 -34
  62. pdd/path_resolution.py +140 -0
  63. pdd/postprocess.py +4 -1
  64. pdd/preprocess.py +68 -12
  65. pdd/preprocess_main.py +33 -1
  66. pdd/prompts/agentic_bug_step10_pr_LLM.prompt +182 -0
  67. pdd/prompts/agentic_bug_step1_duplicate_LLM.prompt +73 -0
  68. pdd/prompts/agentic_bug_step2_docs_LLM.prompt +129 -0
  69. pdd/prompts/agentic_bug_step3_triage_LLM.prompt +95 -0
  70. pdd/prompts/agentic_bug_step4_reproduce_LLM.prompt +97 -0
  71. pdd/prompts/agentic_bug_step5_root_cause_LLM.prompt +123 -0
  72. pdd/prompts/agentic_bug_step6_test_plan_LLM.prompt +107 -0
  73. pdd/prompts/agentic_bug_step7_generate_LLM.prompt +172 -0
  74. pdd/prompts/agentic_bug_step8_verify_LLM.prompt +119 -0
  75. pdd/prompts/agentic_bug_step9_e2e_test_LLM.prompt +289 -0
  76. pdd/prompts/agentic_change_step10_identify_issues_LLM.prompt +1006 -0
  77. pdd/prompts/agentic_change_step11_fix_issues_LLM.prompt +984 -0
  78. pdd/prompts/agentic_change_step12_create_pr_LLM.prompt +131 -0
  79. pdd/prompts/agentic_change_step1_duplicate_LLM.prompt +73 -0
  80. pdd/prompts/agentic_change_step2_docs_LLM.prompt +101 -0
  81. pdd/prompts/agentic_change_step3_research_LLM.prompt +126 -0
  82. pdd/prompts/agentic_change_step4_clarify_LLM.prompt +164 -0
  83. pdd/prompts/agentic_change_step5_docs_change_LLM.prompt +981 -0
  84. pdd/prompts/agentic_change_step6_devunits_LLM.prompt +1005 -0
  85. pdd/prompts/agentic_change_step7_architecture_LLM.prompt +1044 -0
  86. pdd/prompts/agentic_change_step8_analyze_LLM.prompt +1027 -0
  87. pdd/prompts/agentic_change_step9_implement_LLM.prompt +1077 -0
  88. pdd/prompts/agentic_e2e_fix_step1_unit_tests_LLM.prompt +90 -0
  89. pdd/prompts/agentic_e2e_fix_step2_e2e_tests_LLM.prompt +91 -0
  90. pdd/prompts/agentic_e2e_fix_step3_root_cause_LLM.prompt +89 -0
  91. pdd/prompts/agentic_e2e_fix_step4_fix_e2e_tests_LLM.prompt +96 -0
  92. pdd/prompts/agentic_e2e_fix_step5_identify_devunits_LLM.prompt +91 -0
  93. pdd/prompts/agentic_e2e_fix_step6_create_unit_tests_LLM.prompt +106 -0
  94. pdd/prompts/agentic_e2e_fix_step7_verify_tests_LLM.prompt +116 -0
  95. pdd/prompts/agentic_e2e_fix_step8_run_pdd_fix_LLM.prompt +120 -0
  96. pdd/prompts/agentic_e2e_fix_step9_verify_all_LLM.prompt +146 -0
  97. pdd/prompts/agentic_fix_primary_LLM.prompt +2 -2
  98. pdd/prompts/agentic_update_LLM.prompt +192 -338
  99. pdd/prompts/auto_include_LLM.prompt +22 -0
  100. pdd/prompts/change_LLM.prompt +3093 -1
  101. pdd/prompts/detect_change_LLM.prompt +571 -14
  102. pdd/prompts/fix_code_module_errors_LLM.prompt +8 -0
  103. pdd/prompts/fix_errors_from_unit_tests_LLM.prompt +1 -0
  104. pdd/prompts/generate_test_LLM.prompt +20 -1
  105. pdd/prompts/generate_test_from_example_LLM.prompt +115 -0
  106. pdd/prompts/insert_includes_LLM.prompt +262 -252
  107. pdd/prompts/prompt_code_diff_LLM.prompt +119 -0
  108. pdd/prompts/prompt_diff_LLM.prompt +82 -0
  109. pdd/remote_session.py +876 -0
  110. pdd/server/__init__.py +52 -0
  111. pdd/server/app.py +335 -0
  112. pdd/server/click_executor.py +587 -0
  113. pdd/server/executor.py +338 -0
  114. pdd/server/jobs.py +661 -0
  115. pdd/server/models.py +241 -0
  116. pdd/server/routes/__init__.py +31 -0
  117. pdd/server/routes/architecture.py +451 -0
  118. pdd/server/routes/auth.py +364 -0
  119. pdd/server/routes/commands.py +929 -0
  120. pdd/server/routes/config.py +42 -0
  121. pdd/server/routes/files.py +603 -0
  122. pdd/server/routes/prompts.py +1322 -0
  123. pdd/server/routes/websocket.py +473 -0
  124. pdd/server/security.py +243 -0
  125. pdd/server/terminal_spawner.py +209 -0
  126. pdd/server/token_counter.py +222 -0
  127. pdd/summarize_directory.py +236 -237
  128. pdd/sync_animation.py +8 -4
  129. pdd/sync_determine_operation.py +329 -47
  130. pdd/sync_main.py +272 -28
  131. pdd/sync_orchestration.py +136 -75
  132. pdd/template_expander.py +161 -0
  133. pdd/templates/architecture/architecture_json.prompt +41 -46
  134. pdd/trace.py +1 -1
  135. pdd/track_cost.py +0 -13
  136. pdd/unfinished_prompt.py +2 -1
  137. pdd/update_main.py +23 -5
  138. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/METADATA +15 -10
  139. pdd_cli-0.0.118.dist-info/RECORD +227 -0
  140. pdd_cli-0.0.90.dist-info/RECORD +0 -153
  141. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/WHEEL +0 -0
  142. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/entry_points.txt +0 -0
  143. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/licenses/LICENSE +0 -0
  144. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/top_level.txt +0 -0
pdd/server/jobs.py ADDED
@@ -0,0 +1,661 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ import signal
7
+ import subprocess
8
+ import sys
9
+ import threading
10
+ from concurrent.futures import ThreadPoolExecutor
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Any, Awaitable, Callable, Dict, List, Optional
15
+ from uuid import uuid4
16
+
17
+ # Robust import for rich console
18
+ try:
19
+ from rich.console import Console
20
+ console = Console()
21
+ except ImportError:
22
+ class Console:
23
+ def print(self, *args, **kwargs):
24
+ import builtins
25
+ builtins.print(*args)
26
+ console = Console()
27
+
28
+ # Robust import for internal dependencies
29
+ try:
30
+ from .click_executor import ClickCommandExecutor, get_pdd_command
31
+ except ImportError:
32
+ class ClickCommandExecutor:
33
+ def __init__(self, base_context_obj=None, output_callback=None):
34
+ pass
35
+ def execute(self, *args, **kwargs):
36
+ raise NotImplementedError("ClickCommandExecutor not available")
37
+
38
+ def get_pdd_command(name):
39
+ return None
40
+
41
+ from .models import JobStatus
42
+
43
+
44
+ # Global options that must be placed BEFORE the subcommand (defined on cli group)
45
+ GLOBAL_OPTIONS = {
46
+ "force", "strength", "temperature", "time", "verbose", "quiet",
47
+ "output_cost", "review_examples", "local", "context", "list_contexts", "core_dump"
48
+ }
49
+
50
+ # Commands where specific args should be positional (not --options)
51
+ POSITIONAL_ARGS = {
52
+ "sync": ["basename"],
53
+ "generate": ["prompt_file"],
54
+ "test": ["prompt_file", "code_file"],
55
+ "example": ["prompt_file", "code_file"],
56
+ "fix": ["args"], # Always uses variadic "args" (both agentic and manual modes)
57
+ "bug": ["args"],
58
+ "update": ["args"],
59
+ "crash": ["prompt_file", "code_file", "program_file", "error_file"],
60
+ "verify": ["prompt_file", "code_file", "verification_program"],
61
+ "split": ["input_prompt", "input_code", "example_code"],
62
+ "change": ["args"], # Always uses variadic "args" (both agentic and manual modes)
63
+ "detect": ["args"],
64
+ "auto-deps": ["prompt_file", "directory_path"],
65
+ "conflicts": ["prompt_file", "prompt2"],
66
+ "preprocess": ["prompt_file"],
67
+ }
68
+
69
+ # Manual mode file key mappings for fix/change commands
70
+ # These commands use variadic "args" for BOTH modes, but the frontend sends semantic keys
71
+ # for manual mode which we need to convert to ordered positional arguments
72
+ MANUAL_MODE_FILE_KEYS = {
73
+ "fix": ["prompt_file", "code_file", "unit_test_files", "error_file"],
74
+ "change": ["change_prompt_file", "input_code", "input_prompt_file"],
75
+ }
76
+
77
+ logger = logging.getLogger(__name__)
78
+
79
+
80
+ def _find_pdd_executable() -> Optional[str]:
81
+ """Find the pdd executable path."""
82
+ import shutil
83
+
84
+ # First try to find 'pdd' in PATH
85
+ pdd_path = shutil.which("pdd")
86
+ if pdd_path:
87
+ return pdd_path
88
+
89
+ # Try to find 'pdd' in the same directory as the Python interpreter
90
+ python_dir = Path(sys.executable).parent
91
+ pdd_in_python_dir = python_dir / "pdd"
92
+ if pdd_in_python_dir.exists():
93
+ return str(pdd_in_python_dir)
94
+
95
+ return None
96
+
97
+
98
+ def _build_subprocess_command_args(
99
+ command: str,
100
+ args: Optional[Dict[str, Any]],
101
+ options: Optional[Dict[str, Any]]
102
+ ) -> List[str]:
103
+ """
104
+ Build command line arguments for pdd subprocess.
105
+
106
+ Global options (--force, --strength, etc.) are placed BEFORE the subcommand.
107
+ Command-specific options are placed AFTER the subcommand and positional args.
108
+ """
109
+ pdd_exe = _find_pdd_executable()
110
+
111
+ if pdd_exe:
112
+ cmd_args = [pdd_exe]
113
+ else:
114
+ # Fallback: use runpy to run the CLI module
115
+ cmd_args = [
116
+ sys.executable, "-c",
117
+ "import sys; from pdd.cli import cli; sys.exit(cli())"
118
+ ]
119
+
120
+ # Separate global options from command-specific options
121
+ global_opts: Dict[str, Any] = {}
122
+ cmd_opts: Dict[str, Any] = {}
123
+
124
+ if options:
125
+ for key, value in options.items():
126
+ normalized_key = key.replace("-", "_")
127
+ if normalized_key in GLOBAL_OPTIONS:
128
+ global_opts[key] = value
129
+ else:
130
+ cmd_opts[key] = value
131
+
132
+ # Add global options BEFORE the command
133
+ for key, value in global_opts.items():
134
+ if isinstance(value, bool):
135
+ if value:
136
+ cmd_args.append(f"--{key.replace('_', '-')}")
137
+ elif isinstance(value, (list, tuple)):
138
+ for v in value:
139
+ cmd_args.extend([f"--{key.replace('_', '-')}", str(v)])
140
+ elif value is not None:
141
+ cmd_args.extend([f"--{key.replace('_', '-')}", str(value)])
142
+
143
+ # Add the command
144
+ cmd_args.append(command)
145
+
146
+ # Handle fix/change manual mode: convert semantic file keys to positional args
147
+ # and add --manual flag. Both modes use variadic "args" parameter.
148
+ if command in MANUAL_MODE_FILE_KEYS and args and "args" not in args:
149
+ # Manual mode detected: has file keys but no "args" key
150
+ file_keys = MANUAL_MODE_FILE_KEYS[command]
151
+ # Check if any file keys are present
152
+ if any(k in args for k in file_keys):
153
+ # Convert file keys to ordered positional args list (order matters!)
154
+ positional_values = []
155
+ for key in file_keys:
156
+ if key in args and args[key] is not None:
157
+ positional_values.append(str(args[key]))
158
+ # Collect remaining args that aren't file keys (e.g., verification_program)
159
+ remaining_args = {k: v for k, v in args.items() if k not in file_keys}
160
+ # Build new args with positional values
161
+ args = {"args": positional_values}
162
+ # Move remaining args to cmd_opts (they should be options like --verification-program)
163
+ for key, value in remaining_args.items():
164
+ cmd_opts[key] = value
165
+ # Add --manual flag to command-specific options
166
+ cmd_opts["manual"] = True
167
+
168
+ # Get positional arg names for this command
169
+ positional_names = POSITIONAL_ARGS.get(command, [])
170
+
171
+ if args:
172
+ # First, add positional arguments in order
173
+ for pos_name in positional_names:
174
+ if pos_name in args:
175
+ value = args[pos_name]
176
+ if pos_name == "args" and isinstance(value, (list, tuple)):
177
+ cmd_args.extend(str(v) for v in value)
178
+ elif value is not None:
179
+ cmd_args.append(str(value))
180
+
181
+ # Then, add remaining args as options
182
+ for key, value in args.items():
183
+ if key in positional_names:
184
+ continue
185
+ if isinstance(value, bool):
186
+ if value:
187
+ cmd_args.append(f"--{key.replace('_', '-')}")
188
+ elif isinstance(value, (list, tuple)):
189
+ for v in value:
190
+ cmd_args.extend([f"--{key.replace('_', '-')}", str(v)])
191
+ elif value is not None:
192
+ cmd_args.extend([f"--{key.replace('_', '-')}", str(value)])
193
+
194
+ # Add command-specific options
195
+ if cmd_opts:
196
+ for key, value in cmd_opts.items():
197
+ if isinstance(value, bool):
198
+ if value:
199
+ cmd_args.append(f"--{key.replace('_', '-')}")
200
+ elif isinstance(value, (list, tuple)):
201
+ for v in value:
202
+ cmd_args.extend([f"--{key.replace('_', '-')}", str(v)])
203
+ elif value is not None:
204
+ cmd_args.extend([f"--{key.replace('_', '-')}", str(value)])
205
+
206
+ return cmd_args
207
+
208
+
209
+ @dataclass
210
+ class Job:
211
+ """
212
+ Internal representation of a queued or executing job.
213
+ """
214
+ id: str = field(default_factory=lambda: str(uuid4()))
215
+ command: str = ""
216
+ args: Dict[str, Any] = field(default_factory=dict)
217
+ options: Dict[str, Any] = field(default_factory=dict)
218
+ status: JobStatus = JobStatus.QUEUED
219
+ result: Optional[Any] = None
220
+ error: Optional[str] = None
221
+ cost: float = 0.0
222
+ created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
223
+ started_at: Optional[datetime] = None
224
+ completed_at: Optional[datetime] = None
225
+ # Live output during execution (updated in real-time)
226
+ live_stdout: str = ""
227
+ live_stderr: str = ""
228
+
229
+ def to_dict(self) -> Dict[str, Any]:
230
+ return {
231
+ "id": self.id,
232
+ "command": self.command,
233
+ "args": self.args,
234
+ "options": self.options,
235
+ "status": self.status.value,
236
+ "result": self.result,
237
+ "error": self.error,
238
+ "cost": self.cost,
239
+ "created_at": self.created_at.isoformat(),
240
+ "started_at": self.started_at.isoformat() if self.started_at else None,
241
+ "completed_at": self.completed_at.isoformat() if self.completed_at else None,
242
+ "live_stdout": self.live_stdout,
243
+ "live_stderr": self.live_stderr,
244
+ }
245
+
246
+
247
+ class JobCallbacks:
248
+ """Async callback handlers for job lifecycle events."""
249
+
250
+ def __init__(self):
251
+ self._on_start: List[Callable[[Job], Awaitable[None]]] = []
252
+ self._on_output: List[Callable[[Job, str, str], Awaitable[None]]] = []
253
+ self._on_progress: List[Callable[[Job, int, int, str], Awaitable[None]]] = []
254
+ self._on_complete: List[Callable[[Job], Awaitable[None]]] = []
255
+
256
+ def on_start(self, callback: Callable[[Job], Awaitable[None]]) -> None:
257
+ self._on_start.append(callback)
258
+
259
+ def on_output(self, callback: Callable[[Job, str, str], Awaitable[None]]) -> None:
260
+ self._on_output.append(callback)
261
+
262
+ def on_progress(self, callback: Callable[[Job, int, int, str], Awaitable[None]]) -> None:
263
+ self._on_progress.append(callback)
264
+
265
+ def on_complete(self, callback: Callable[[Job], Awaitable[None]]) -> None:
266
+ self._on_complete.append(callback)
267
+
268
+ async def emit_start(self, job: Job) -> None:
269
+ for callback in self._on_start:
270
+ try:
271
+ await callback(job)
272
+ except Exception as e:
273
+ console.print(f"[red]Error in on_start callback: {e}[/red]")
274
+
275
+ async def emit_output(self, job: Job, stream_type: str, text: str) -> None:
276
+ for callback in self._on_output:
277
+ try:
278
+ await callback(job, stream_type, text)
279
+ except Exception as e:
280
+ console.print(f"[red]Error in on_output callback: {e}[/red]")
281
+
282
+ async def emit_progress(self, job: Job, current: int, total: int, message: str = "") -> None:
283
+ for callback in self._on_progress:
284
+ try:
285
+ await callback(job, current, total, message)
286
+ except Exception as e:
287
+ console.print(f"[red]Error in on_progress callback: {e}[/red]")
288
+
289
+ async def emit_complete(self, job: Job) -> None:
290
+ for callback in self._on_complete:
291
+ try:
292
+ await callback(job)
293
+ except Exception as e:
294
+ console.print(f"[red]Error in on_complete callback: {e}[/red]")
295
+
296
+
297
+ class JobManager:
298
+ """
299
+ Manages async job execution, queuing, and lifecycle tracking.
300
+ """
301
+
302
+ def __init__(
303
+ self,
304
+ max_concurrent: int = 1,
305
+ executor: Optional[Callable[[Job], Awaitable[Dict[str, Any]]]] = None,
306
+ project_root: Optional[Path] = None,
307
+ ):
308
+ self.max_concurrent = max_concurrent
309
+ self.callbacks = JobCallbacks()
310
+ self.project_root = project_root or Path.cwd()
311
+
312
+ self._jobs: Dict[str, Job] = {}
313
+ self._tasks: Dict[str, asyncio.Task] = {}
314
+ self._semaphore = asyncio.Semaphore(max_concurrent)
315
+ self._cancel_events: Dict[str, asyncio.Event] = {}
316
+
317
+ # Track running subprocesses for cancellation
318
+ self._processes: Dict[str, subprocess.Popen] = {}
319
+ self._process_lock = threading.Lock()
320
+
321
+ self._thread_pool = ThreadPoolExecutor(
322
+ max_workers=max_concurrent,
323
+ thread_name_prefix="pdd_job_worker"
324
+ )
325
+
326
+ self._custom_executor = executor
327
+
328
+ async def submit(
329
+ self,
330
+ command: str,
331
+ args: Dict[str, Any] = None,
332
+ options: Dict[str, Any] = None,
333
+ ) -> Job:
334
+ job = Job(
335
+ command=command,
336
+ args=args or {},
337
+ options=options or {},
338
+ )
339
+
340
+ self._jobs[job.id] = job
341
+ self._cancel_events[job.id] = asyncio.Event()
342
+
343
+ console.print(f"[blue]Job submitted:[/blue] {job.id} ({command})")
344
+
345
+ task = asyncio.create_task(self._execute_wrapper(job))
346
+ self._tasks[job.id] = task
347
+
348
+ # Callback to handle cleanup and edge-case cancellation (cancelled before start)
349
+ def _on_task_done(t: asyncio.Task):
350
+ if job.id in self._tasks:
351
+ del self._tasks[job.id]
352
+
353
+ # If task was cancelled but job status wasn't updated (e.g. never started running)
354
+ if t.cancelled() and job.status == JobStatus.QUEUED:
355
+ job.status = JobStatus.CANCELLED
356
+ if not job.completed_at:
357
+ job.completed_at = datetime.now(timezone.utc)
358
+ console.print(f"[yellow]Job cancelled (Task Done):[/yellow] {job.id}")
359
+
360
+ task.add_done_callback(_on_task_done)
361
+
362
+ return job
363
+
364
+ async def _execute_wrapper(self, job: Job) -> None:
365
+ try:
366
+ async with self._semaphore:
367
+ await self._execute_job(job)
368
+ except asyncio.CancelledError:
369
+ # Handle cancellation while waiting for semaphore
370
+ if job.status == JobStatus.QUEUED:
371
+ job.status = JobStatus.CANCELLED
372
+ job.completed_at = datetime.now(timezone.utc)
373
+ console.print(f"[yellow]Job cancelled (Queue):[/yellow] {job.id}")
374
+ raise # Re-raise to ensure task is marked as cancelled for the callback
375
+
376
+ async def _execute_job(self, job: Job) -> None:
377
+ try:
378
+ # 1. Check cancellation before starting
379
+ if self._cancel_events[job.id].is_set():
380
+ job.status = JobStatus.CANCELLED
381
+ console.print(f"[yellow]Job cancelled (Queued):[/yellow] {job.id}")
382
+ return
383
+
384
+ # 2. Update status and notify
385
+ job.status = JobStatus.RUNNING
386
+ job.started_at = datetime.now(timezone.utc)
387
+ await self.callbacks.emit_start(job)
388
+
389
+ # 3. Execute
390
+ result = None
391
+
392
+ if self._custom_executor:
393
+ result = await self._custom_executor(job)
394
+ else:
395
+ result = await self._run_click_command(job)
396
+
397
+ # 4. Handle Result
398
+ if self._cancel_events[job.id].is_set():
399
+ job.status = JobStatus.CANCELLED
400
+ console.print(f"[yellow]Job cancelled:[/yellow] {job.id}")
401
+ else:
402
+ job.result = result
403
+ job.cost = float(result.get("cost", 0.0)) if isinstance(result, dict) else 0.0
404
+ job.status = JobStatus.COMPLETED
405
+ console.print(f"[green]Job completed:[/green] {job.id}")
406
+
407
+ except asyncio.CancelledError:
408
+ job.status = JobStatus.CANCELLED
409
+ console.print(f"[yellow]Job cancelled (Task):[/yellow] {job.id}")
410
+ raise # Re-raise to propagate cancellation
411
+
412
+ except Exception as e:
413
+ job.error = str(e)
414
+ job.status = JobStatus.FAILED
415
+ console.print(f"[red]Job failed:[/red] {job.id} - {e}")
416
+
417
+ finally:
418
+ # 5. Cleanup and Notify
419
+ if not job.completed_at:
420
+ job.completed_at = datetime.now(timezone.utc)
421
+ await self.callbacks.emit_complete(job)
422
+
423
+ if job.id in self._cancel_events:
424
+ del self._cancel_events[job.id]
425
+
426
+ async def _run_click_command(self, job: Job) -> Dict[str, Any]:
427
+ """
428
+ Run a PDD command as a subprocess with output streaming and cancellation support.
429
+
430
+ This uses subprocess execution instead of direct Click invocation to enable:
431
+ - Proper cancellation via SIGTERM/SIGKILL
432
+ - Process isolation
433
+ - Output streaming
434
+ """
435
+ loop = asyncio.get_running_loop()
436
+
437
+ # Build command args - add --force to skip confirmation prompts
438
+ options_with_force = dict(job.options) if job.options else {}
439
+ options_with_force['force'] = True # Skip all confirmation prompts
440
+ cmd_args = _build_subprocess_command_args(job.command, job.args, options_with_force)
441
+
442
+ # Set up environment for headless execution
443
+ env = os.environ.copy()
444
+ env['CI'] = '1'
445
+ env['PDD_FORCE'] = '1'
446
+ env['TERM'] = 'dumb'
447
+ env['PDD_SKIP_UPDATE_CHECK'] = '1' # Skip update prompts
448
+
449
+ stdout_lines = []
450
+ stderr_lines = []
451
+
452
+ def run_subprocess():
453
+ """Run subprocess in thread with output streaming."""
454
+ try:
455
+ process = subprocess.Popen(
456
+ cmd_args,
457
+ stdout=subprocess.PIPE,
458
+ stderr=subprocess.PIPE,
459
+ stdin=subprocess.DEVNULL,
460
+ cwd=str(self.project_root),
461
+ env=env,
462
+ text=True,
463
+ bufsize=1, # Line buffered
464
+ )
465
+
466
+ # Track process for cancellation
467
+ with self._process_lock:
468
+ self._processes[job.id] = process
469
+
470
+ # Read output in real-time
471
+ def read_stream(stream, stream_type, lines_list):
472
+ try:
473
+ for line in iter(stream.readline, ''):
474
+ if line:
475
+ lines_list.append(line)
476
+ # Update live output on the job for polling
477
+ if stream_type == "stdout":
478
+ job.live_stdout += line
479
+ else:
480
+ job.live_stderr += line
481
+ # Emit output callback
482
+ if job.status == JobStatus.RUNNING:
483
+ asyncio.run_coroutine_threadsafe(
484
+ self.callbacks.emit_output(job, stream_type, line),
485
+ loop
486
+ )
487
+ except Exception:
488
+ pass
489
+ finally:
490
+ stream.close()
491
+
492
+ # Start threads to read stdout and stderr
493
+ stdout_thread = threading.Thread(
494
+ target=read_stream,
495
+ args=(process.stdout, "stdout", stdout_lines)
496
+ )
497
+ stderr_thread = threading.Thread(
498
+ target=read_stream,
499
+ args=(process.stderr, "stderr", stderr_lines)
500
+ )
501
+
502
+ stdout_thread.start()
503
+ stderr_thread.start()
504
+
505
+ # Wait for process to complete
506
+ exit_code = process.wait()
507
+
508
+ # Wait for output threads to finish
509
+ stdout_thread.join(timeout=5)
510
+ stderr_thread.join(timeout=5)
511
+
512
+ return exit_code
513
+
514
+ finally:
515
+ # Clean up process tracking
516
+ with self._process_lock:
517
+ self._processes.pop(job.id, None)
518
+
519
+ # Run in thread pool
520
+ exit_code = await loop.run_in_executor(self._thread_pool, run_subprocess)
521
+
522
+ # Check if cancelled
523
+ if self._cancel_events.get(job.id) and self._cancel_events[job.id].is_set():
524
+ raise asyncio.CancelledError("Job was cancelled")
525
+
526
+ stdout_text = ''.join(stdout_lines)
527
+ stderr_text = ''.join(stderr_lines)
528
+
529
+ if exit_code != 0:
530
+ # Combine stdout and stderr for complete error context
531
+ # Filter out INFO/DEBUG logs to find the actual error message
532
+ all_output = stdout_text + "\n" + stderr_text if stderr_text else stdout_text
533
+
534
+ # Try to find actual error lines (not INFO/DEBUG logs)
535
+ error_lines = []
536
+ for line in all_output.split('\n'):
537
+ line_stripped = line.strip()
538
+ if not line_stripped:
539
+ continue
540
+ # Skip common log prefixes
541
+ if ' - INFO - ' in line or ' - DEBUG - ' in line:
542
+ continue
543
+ # Skip lines that are just timestamps with INFO
544
+ if line_stripped.startswith('202') and ' INFO ' in line:
545
+ continue
546
+ error_lines.append(line)
547
+
548
+ if error_lines:
549
+ error_msg = '\n'.join(error_lines[-50:]) # Last 50 non-INFO lines
550
+ else:
551
+ # No non-INFO lines found, use all output
552
+ error_msg = all_output or f"Command failed with exit code {exit_code}"
553
+
554
+ raise RuntimeError(error_msg)
555
+
556
+ return {
557
+ "stdout": stdout_text,
558
+ "stderr": stderr_text,
559
+ "exit_code": exit_code,
560
+ "cost": 0.0
561
+ }
562
+
563
+ def get_job(self, job_id: str) -> Optional[Job]:
564
+ return self._jobs.get(job_id)
565
+
566
+ def get_all_jobs(self) -> Dict[str, Job]:
567
+ return self._jobs.copy()
568
+
569
+ def get_active_jobs(self) -> Dict[str, Job]:
570
+ return {
571
+ job_id: job
572
+ for job_id, job in self._jobs.items()
573
+ if job.status in (JobStatus.QUEUED, JobStatus.RUNNING)
574
+ }
575
+
576
+ async def cancel(self, job_id: str) -> bool:
577
+ """
578
+ Cancel a running job by terminating its subprocess.
579
+
580
+ This method:
581
+ 1. Sets the cancel event to signal cancellation
582
+ 2. Terminates the subprocess (SIGTERM, then SIGKILL if needed)
583
+ 3. Cancels the async task
584
+
585
+ Returns True if cancellation was initiated, False if job already finished.
586
+ """
587
+ job = self._jobs.get(job_id)
588
+ if not job:
589
+ return False
590
+
591
+ if job.status in (JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED):
592
+ return False
593
+
594
+ # Set cancel event first
595
+ if job_id in self._cancel_events:
596
+ self._cancel_events[job_id].set()
597
+
598
+ # Terminate the subprocess if running
599
+ with self._process_lock:
600
+ process = self._processes.get(job_id)
601
+ if process and process.poll() is None:
602
+ console.print(f"[yellow]Terminating subprocess for job:[/yellow] {job_id}")
603
+ try:
604
+ # Try graceful termination first
605
+ process.terminate()
606
+
607
+ # Give it a moment to terminate
608
+ try:
609
+ process.wait(timeout=2)
610
+ except subprocess.TimeoutExpired:
611
+ # Force kill if it doesn't respond
612
+ console.print(f"[yellow]Force killing subprocess for job:[/yellow] {job_id}")
613
+ process.kill()
614
+ process.wait(timeout=2)
615
+ except Exception as e:
616
+ console.print(f"[red]Error terminating subprocess: {e}[/red]")
617
+
618
+ # Cancel the async task
619
+ if job_id in self._tasks:
620
+ self._tasks[job_id].cancel()
621
+
622
+ # Update job status
623
+ job.status = JobStatus.CANCELLED
624
+ job.completed_at = datetime.now(timezone.utc)
625
+
626
+ console.print(f"[yellow]Cancellation completed for job:[/yellow] {job_id}")
627
+ return True
628
+
629
+ def cleanup_old_jobs(self, max_age_seconds: float = 3600) -> int:
630
+ now = datetime.now(timezone.utc)
631
+ to_remove = []
632
+
633
+ for job_id, job in self._jobs.items():
634
+ if job.completed_at:
635
+ age = (now - job.completed_at).total_seconds()
636
+ if age > max_age_seconds:
637
+ to_remove.append(job_id)
638
+
639
+ for job_id in to_remove:
640
+ del self._jobs[job_id]
641
+ if job_id in self._cancel_events:
642
+ del self._cancel_events[job_id]
643
+ if job_id in self._tasks:
644
+ del self._tasks[job_id]
645
+
646
+ if to_remove:
647
+ console.print(f"[dim]Cleaned up {len(to_remove)} old jobs[/dim]")
648
+
649
+ return len(to_remove)
650
+
651
+ async def shutdown(self) -> None:
652
+ console.print("[bold red]Shutting down JobManager...[/bold red]")
653
+
654
+ active_jobs = list(self.get_active_jobs().keys())
655
+ for job_id in active_jobs:
656
+ await self.cancel(job_id)
657
+
658
+ if active_jobs:
659
+ await asyncio.sleep(0.1)
660
+
661
+ self._thread_pool.shutdown(wait=False)