agentic-devtools 0.2.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 (92) hide show
  1. agdt_ai_helpers/__init__.py +34 -0
  2. agentic_devtools/__init__.py +8 -0
  3. agentic_devtools/background_tasks.py +598 -0
  4. agentic_devtools/cli/__init__.py +1 -0
  5. agentic_devtools/cli/azure_devops/__init__.py +222 -0
  6. agentic_devtools/cli/azure_devops/async_commands.py +1218 -0
  7. agentic_devtools/cli/azure_devops/auth.py +34 -0
  8. agentic_devtools/cli/azure_devops/commands.py +728 -0
  9. agentic_devtools/cli/azure_devops/config.py +49 -0
  10. agentic_devtools/cli/azure_devops/file_review_commands.py +1038 -0
  11. agentic_devtools/cli/azure_devops/helpers.py +561 -0
  12. agentic_devtools/cli/azure_devops/mark_reviewed.py +756 -0
  13. agentic_devtools/cli/azure_devops/pipeline_commands.py +724 -0
  14. agentic_devtools/cli/azure_devops/pr_summary_commands.py +579 -0
  15. agentic_devtools/cli/azure_devops/pull_request_details_commands.py +596 -0
  16. agentic_devtools/cli/azure_devops/review_commands.py +700 -0
  17. agentic_devtools/cli/azure_devops/review_helpers.py +191 -0
  18. agentic_devtools/cli/azure_devops/review_jira.py +308 -0
  19. agentic_devtools/cli/azure_devops/review_prompts.py +263 -0
  20. agentic_devtools/cli/azure_devops/run_details_commands.py +935 -0
  21. agentic_devtools/cli/azure_devops/vpn_toggle.py +1220 -0
  22. agentic_devtools/cli/git/__init__.py +91 -0
  23. agentic_devtools/cli/git/async_commands.py +294 -0
  24. agentic_devtools/cli/git/commands.py +399 -0
  25. agentic_devtools/cli/git/core.py +152 -0
  26. agentic_devtools/cli/git/diff.py +210 -0
  27. agentic_devtools/cli/git/operations.py +737 -0
  28. agentic_devtools/cli/jira/__init__.py +114 -0
  29. agentic_devtools/cli/jira/adf.py +105 -0
  30. agentic_devtools/cli/jira/async_commands.py +439 -0
  31. agentic_devtools/cli/jira/async_status.py +27 -0
  32. agentic_devtools/cli/jira/commands.py +28 -0
  33. agentic_devtools/cli/jira/comment_commands.py +141 -0
  34. agentic_devtools/cli/jira/config.py +69 -0
  35. agentic_devtools/cli/jira/create_commands.py +293 -0
  36. agentic_devtools/cli/jira/formatting.py +131 -0
  37. agentic_devtools/cli/jira/get_commands.py +287 -0
  38. agentic_devtools/cli/jira/helpers.py +278 -0
  39. agentic_devtools/cli/jira/parse_error_report.py +352 -0
  40. agentic_devtools/cli/jira/role_commands.py +560 -0
  41. agentic_devtools/cli/jira/state_helpers.py +39 -0
  42. agentic_devtools/cli/jira/update_commands.py +222 -0
  43. agentic_devtools/cli/jira/vpn_wrapper.py +58 -0
  44. agentic_devtools/cli/release/__init__.py +5 -0
  45. agentic_devtools/cli/release/commands.py +113 -0
  46. agentic_devtools/cli/release/helpers.py +113 -0
  47. agentic_devtools/cli/runner.py +318 -0
  48. agentic_devtools/cli/state.py +174 -0
  49. agentic_devtools/cli/subprocess_utils.py +109 -0
  50. agentic_devtools/cli/tasks/__init__.py +28 -0
  51. agentic_devtools/cli/tasks/commands.py +851 -0
  52. agentic_devtools/cli/testing.py +442 -0
  53. agentic_devtools/cli/workflows/__init__.py +80 -0
  54. agentic_devtools/cli/workflows/advancement.py +204 -0
  55. agentic_devtools/cli/workflows/base.py +240 -0
  56. agentic_devtools/cli/workflows/checklist.py +278 -0
  57. agentic_devtools/cli/workflows/commands.py +1610 -0
  58. agentic_devtools/cli/workflows/manager.py +802 -0
  59. agentic_devtools/cli/workflows/preflight.py +323 -0
  60. agentic_devtools/cli/workflows/worktree_setup.py +1110 -0
  61. agentic_devtools/dispatcher.py +704 -0
  62. agentic_devtools/file_locking.py +203 -0
  63. agentic_devtools/prompts/__init__.py +38 -0
  64. agentic_devtools/prompts/apply-pull-request-review-suggestions/default-initiate-prompt.md +82 -0
  65. agentic_devtools/prompts/create-jira-epic/default-initiate-prompt.md +63 -0
  66. agentic_devtools/prompts/create-jira-issue/default-initiate-prompt.md +306 -0
  67. agentic_devtools/prompts/create-jira-subtask/default-initiate-prompt.md +57 -0
  68. agentic_devtools/prompts/loader.py +377 -0
  69. agentic_devtools/prompts/pull-request-review/default-completion-prompt.md +45 -0
  70. agentic_devtools/prompts/pull-request-review/default-decision-prompt.md +63 -0
  71. agentic_devtools/prompts/pull-request-review/default-file-review-prompt.md +69 -0
  72. agentic_devtools/prompts/pull-request-review/default-initiate-prompt.md +50 -0
  73. agentic_devtools/prompts/pull-request-review/default-summary-prompt.md +40 -0
  74. agentic_devtools/prompts/update-jira-issue/default-initiate-prompt.md +78 -0
  75. agentic_devtools/prompts/work-on-jira-issue/default-checklist-creation-prompt.md +58 -0
  76. agentic_devtools/prompts/work-on-jira-issue/default-commit-prompt.md +47 -0
  77. agentic_devtools/prompts/work-on-jira-issue/default-completion-prompt.md +65 -0
  78. agentic_devtools/prompts/work-on-jira-issue/default-implementation-prompt.md +66 -0
  79. agentic_devtools/prompts/work-on-jira-issue/default-implementation-review-prompt.md +60 -0
  80. agentic_devtools/prompts/work-on-jira-issue/default-initiate-prompt.md +67 -0
  81. agentic_devtools/prompts/work-on-jira-issue/default-planning-prompt.md +50 -0
  82. agentic_devtools/prompts/work-on-jira-issue/default-pull-request-prompt.md +56 -0
  83. agentic_devtools/prompts/work-on-jira-issue/default-retrieve-prompt.md +29 -0
  84. agentic_devtools/prompts/work-on-jira-issue/default-setup-prompt.md +19 -0
  85. agentic_devtools/prompts/work-on-jira-issue/default-verification-prompt.md +73 -0
  86. agentic_devtools/state.py +754 -0
  87. agentic_devtools/task_state.py +902 -0
  88. agentic_devtools-0.2.0.dist-info/METADATA +544 -0
  89. agentic_devtools-0.2.0.dist-info/RECORD +92 -0
  90. agentic_devtools-0.2.0.dist-info/WHEEL +4 -0
  91. agentic_devtools-0.2.0.dist-info/entry_points.txt +79 -0
  92. agentic_devtools-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,851 @@
1
+ """
2
+ Task monitoring commands.
3
+
4
+ CLI commands for monitoring and managing background tasks.
5
+ """
6
+
7
+ import argparse
8
+ import sys
9
+ from datetime import datetime, timezone
10
+ from typing import List, Optional
11
+
12
+ from ...background_tasks import get_task_log_content
13
+ from ...task_state import (
14
+ BackgroundTask,
15
+ TaskStatus,
16
+ cleanup_expired_tasks,
17
+ get_background_tasks,
18
+ get_other_incomplete_tasks,
19
+ get_task_by_id,
20
+ )
21
+
22
+
23
+ def _get_task_id_from_args_or_state(_argv: Optional[List[str]] = None) -> str:
24
+ """
25
+ Get task ID from CLI args or state.
26
+
27
+ If --id is provided, updates state with the new task_id.
28
+
29
+ Args:
30
+ _argv: Optional list of CLI arguments (for testing)
31
+
32
+ Returns:
33
+ Task ID string
34
+
35
+ Raises:
36
+ SystemExit: If no task ID is available
37
+ """
38
+ from ...state import get_value, set_value
39
+
40
+ parser = argparse.ArgumentParser(add_help=False)
41
+ parser.add_argument("--id", type=str, default=None, help="Task ID to track")
42
+
43
+ args, _ = parser.parse_known_args(_argv)
44
+
45
+ if args.id:
46
+ # Update state with the provided task ID
47
+ set_value("background.task_id", args.id)
48
+ return args.id
49
+
50
+ task_id = get_value("background.task_id")
51
+
52
+ if not task_id:
53
+ print("Error: No task ID specified.")
54
+ print('Set a task ID with: agdt-set background.task_id <task-id> or use --id "<task-id>"')
55
+ sys.exit(1)
56
+
57
+ return task_id
58
+
59
+
60
+ def _safe_print(text: str) -> None:
61
+ """
62
+ Print text safely, handling unicode encoding errors.
63
+
64
+ On Windows with cp1252 encoding, emoji characters may fail to encode.
65
+ This function replaces non-encodable characters with ASCII equivalents.
66
+ """
67
+ try:
68
+ print(text)
69
+ except UnicodeEncodeError:
70
+ # Replace emoji with ASCII equivalents
71
+ ascii_replacements = {
72
+ "\u2705": "[OK]", # āœ…
73
+ "\u274c": "[FAIL]", # āŒ
74
+ "\u23f3": "[...]", # ā³
75
+ "\U0001f504": "[~]", # šŸ”„
76
+ "\u2753": "[?]", # ā“
77
+ "\u23f0": "[TIMEOUT]", # ā°
78
+ "\U0001f4cb": "[LIST]", # šŸ“‹
79
+ "\u2022": "-", # •
80
+ }
81
+ safe_text = text
82
+ for emoji, replacement in ascii_replacements.items():
83
+ safe_text = safe_text.replace(emoji, replacement)
84
+ # Final fallback: encode with replace
85
+ print(safe_text.encode(sys.stdout.encoding or "utf-8", errors="replace").decode())
86
+
87
+
88
+ def _format_timestamp(ts: Optional[str]) -> str:
89
+ """Format an ISO timestamp for display."""
90
+ if not ts:
91
+ return "N/A"
92
+ try:
93
+ dt = datetime.fromisoformat(ts)
94
+ return dt.strftime("%Y-%m-%d %H:%M:%S")
95
+ except (ValueError, TypeError):
96
+ return ts
97
+
98
+
99
+ def _format_duration(task: BackgroundTask) -> str:
100
+ """Calculate and format task duration."""
101
+ if not task.start_time:
102
+ return "Not started"
103
+
104
+ start = datetime.fromisoformat(task.start_time)
105
+
106
+ if task.end_time:
107
+ end = datetime.fromisoformat(task.end_time)
108
+ elif task.status == TaskStatus.RUNNING:
109
+ end = datetime.now(timezone.utc)
110
+ else:
111
+ return "Unknown"
112
+
113
+ duration = end - start
114
+ total_seconds = int(duration.total_seconds())
115
+
116
+ if total_seconds < 60:
117
+ return f"{total_seconds}s"
118
+ elif total_seconds < 3600:
119
+ minutes = total_seconds // 60
120
+ seconds = total_seconds % 60
121
+ return f"{minutes}m {seconds}s"
122
+ else:
123
+ hours = total_seconds // 3600
124
+ minutes = (total_seconds % 3600) // 60
125
+ return f"{hours}h {minutes}m"
126
+
127
+
128
+ def _status_indicator(status: TaskStatus) -> str:
129
+ """Get a status indicator symbol."""
130
+ indicators = {
131
+ TaskStatus.PENDING: "ā³",
132
+ TaskStatus.RUNNING: "šŸ”„",
133
+ TaskStatus.COMPLETED: "āœ…",
134
+ TaskStatus.FAILED: "āŒ",
135
+ }
136
+ return indicators.get(status, "ā“")
137
+
138
+
139
+ def list_tasks() -> None:
140
+ """
141
+ List all background tasks.
142
+
143
+ Entry point: agdt-tasks
144
+ """
145
+ tasks = get_background_tasks()
146
+
147
+ if not tasks:
148
+ print("No background tasks found.")
149
+ return
150
+
151
+ # Sort by start_time descending (most recent first)
152
+ sorted_tasks = sorted(
153
+ tasks,
154
+ key=lambda t: t.start_time or "",
155
+ reverse=True,
156
+ )
157
+
158
+ print(f"\n{'ID':<36} {'Status':<12} {'Command':<30} {'Duration':<10} Created")
159
+ print("-" * 110)
160
+
161
+ for task in sorted_tasks:
162
+ status_str = f"{_status_indicator(task.status)} {task.status.value}"
163
+ command_display = task.command[:27] + "..." if len(task.command) > 30 else task.command
164
+ duration = _format_duration(task)
165
+ created = _format_timestamp(task.start_time)
166
+
167
+ _safe_print(f"{task.id:<36} {status_str:<12} {command_display:<30} {duration:<10} {created}")
168
+
169
+ print(f"\nTotal: {len(tasks)} task(s)")
170
+
171
+ # Count by status
172
+ status_counts = {}
173
+ for task in tasks:
174
+ status_counts[task.status] = status_counts.get(task.status, 0) + 1
175
+
176
+ status_summary = ", ".join(
177
+ f"{status.value}: {count}" for status, count in sorted(status_counts.items(), key=lambda x: x[0].value)
178
+ )
179
+ print(f"By status: {status_summary}")
180
+
181
+
182
+ def task_status(_argv: Optional[List[str]] = None) -> None:
183
+ """
184
+ Show detailed status of a specific task.
185
+
186
+ Entry point: agdt-task-status
187
+
188
+ Args:
189
+ _argv: Optional list of CLI arguments (for testing)
190
+
191
+ CLI args:
192
+ --id: Task ID to show status for (overrides state, updates background.task_id)
193
+
194
+ Reads task ID from state: background.task_id (if --id not provided)
195
+ """
196
+ task_id = _get_task_id_from_args_or_state(_argv)
197
+
198
+ task = get_task_by_id(task_id)
199
+
200
+ if not task:
201
+ print(f"Error: Task '{task_id}' not found.")
202
+ sys.exit(1)
203
+
204
+ print(f"\n{'=' * 60}")
205
+ print(f"Task Details: {task.id}")
206
+ print(f"{'=' * 60}")
207
+ print(f" Command: {task.command}")
208
+ _safe_print(f" Status: {_status_indicator(task.status)} {task.status.value}")
209
+ print(f" Started: {_format_timestamp(task.start_time)}")
210
+ print(f" Completed: {_format_timestamp(task.end_time)}")
211
+ print(f" Duration: {_format_duration(task)}")
212
+
213
+ if task.exit_code is not None:
214
+ print(f" Exit Code: {task.exit_code}")
215
+
216
+ if task.log_file:
217
+ print(f" Log File: {task.log_file}")
218
+
219
+ if task.error_message:
220
+ print(f" Error: {task.error_message}")
221
+
222
+ print(f"{'=' * 60}")
223
+
224
+
225
+ def task_log(_argv: Optional[List[str]] = None) -> None:
226
+ """
227
+ Display task log contents.
228
+
229
+ Entry point: agdt-task-log
230
+
231
+ Args:
232
+ _argv: Optional list of CLI arguments (for testing)
233
+
234
+ CLI args:
235
+ --id: Task ID to show log for (overrides state, updates background.task_id)
236
+
237
+ Reads task ID from state: background.task_id (if --id not provided)
238
+ Optional state keys:
239
+ - background.log_lines: Number of lines to show (default: all, use negative for tail)
240
+ """
241
+ from ...state import get_value
242
+
243
+ task_id = _get_task_id_from_args_or_state(_argv)
244
+
245
+ task = get_task_by_id(task_id)
246
+
247
+ if not task:
248
+ print(f"Error: Task '{task_id}' not found.")
249
+ sys.exit(1)
250
+
251
+ log_content = get_task_log_content(task_id)
252
+
253
+ if log_content is None:
254
+ print(f"No log file available for task '{task_id}'.")
255
+ if task.log_file:
256
+ print(f"Expected log file: {task.log_file}")
257
+ sys.exit(1)
258
+
259
+ # Check for line limit
260
+ lines_str = get_value("background.log_lines")
261
+ if lines_str:
262
+ try:
263
+ lines_limit = int(lines_str)
264
+ log_lines = log_content.splitlines()
265
+
266
+ if lines_limit < 0:
267
+ # Tail mode: show last N lines
268
+ log_lines = log_lines[lines_limit:]
269
+ elif lines_limit > 0:
270
+ # Head mode: show first N lines
271
+ log_lines = log_lines[:lines_limit]
272
+
273
+ log_content = "\n".join(log_lines)
274
+ except ValueError:
275
+ pass # Ignore invalid line count
276
+
277
+ print(f"\n--- Log for task {task_id} ---")
278
+ print(f"Command: {task.command}")
279
+ _safe_print(f"Status: {_status_indicator(task.status)} {task.status.value}")
280
+ print("-" * 50)
281
+ print(log_content)
282
+ print("-" * 50)
283
+
284
+
285
+ def _parse_wait_args(_argv: Optional[List[str]] = None) -> argparse.Namespace:
286
+ """
287
+ Parse arguments for task_wait command.
288
+
289
+ Args:
290
+ _argv: Optional list of CLI arguments (for testing)
291
+
292
+ Returns:
293
+ Parsed arguments namespace
294
+ """
295
+ parser = argparse.ArgumentParser(
296
+ prog="agdt-task-wait",
297
+ description="Wait for a background task to complete",
298
+ add_help=True,
299
+ )
300
+ parser.add_argument("--id", type=str, default=None, help="Task ID to wait for")
301
+ parser.add_argument(
302
+ "--wait-interval",
303
+ type=float,
304
+ default=1.0,
305
+ help="Seconds to wait between status checks (default: 1.0)",
306
+ )
307
+ parser.add_argument(
308
+ "--timeout",
309
+ type=float,
310
+ default=300.0,
311
+ help="Maximum seconds since task start before timeout (default: 300)",
312
+ )
313
+
314
+ return parser.parse_args(_argv or [])
315
+
316
+
317
+ def _check_task_timeout(task: BackgroundTask, timeout: float) -> bool:
318
+ """
319
+ Check if task has exceeded timeout based on its start_time.
320
+
321
+ Args:
322
+ task: The background task to check
323
+ timeout: Maximum seconds since task start
324
+
325
+ Returns:
326
+ True if task has timed out, False otherwise
327
+ """
328
+ if not task.start_time:
329
+ return False # Can't determine timeout without start time
330
+
331
+ try:
332
+ # Parse start_time (ISO format)
333
+ start_dt = datetime.fromisoformat(task.start_time.replace("Z", "+00:00"))
334
+ elapsed = (datetime.now(timezone.utc) - start_dt).total_seconds()
335
+ return elapsed > timeout
336
+ except (ValueError, TypeError):
337
+ return False # Can't determine timeout with invalid start time
338
+
339
+
340
+ def task_wait(_argv: Optional[List[str]] = None) -> None:
341
+ """
342
+ Wait for task completion and auto-progress workflow.
343
+
344
+ Entry point: agdt-task-wait
345
+
346
+ This command checks task status twice (with a configurable wait between checks):
347
+ 1. If task completed → Handles success/failure as appropriate
348
+ 2. If task still running → Tells AI agent to call agdt-task-wait again
349
+ 3. If task timed out (based on start_time) → Reports timeout
350
+
351
+ After task completion:
352
+ 1. If task failed → Shows log with fix instructions
353
+ 2. If other incomplete most-recent-per-command tasks exist → Instructs to wait for them
354
+ 3. If any most-recent-per-command task failed → Instructs to review it
355
+ 4. If all clear → Automatically runs agdt-get-next-workflow-prompt
356
+
357
+ Args:
358
+ _argv: Optional list of CLI arguments (for testing)
359
+
360
+ CLI args:
361
+ --id: Task ID to wait for (overrides state, updates background.task_id)
362
+ --wait-interval: Seconds to wait between checks (default: 1.0)
363
+ --timeout: Max seconds since task start before timeout (default: 300)
364
+
365
+ Reads task ID from state: background.task_id (if --id not provided)
366
+ """
367
+ import time
368
+
369
+ from ...state import get_value, set_value
370
+
371
+ # Parse args
372
+ args = _parse_wait_args(_argv)
373
+
374
+ # Handle task ID (from arg or state)
375
+ if args.id:
376
+ set_value("background.task_id", args.id)
377
+ task_id = args.id
378
+ else:
379
+ task_id = get_value("background.task_id")
380
+ if not task_id:
381
+ print("Error: No task ID specified.")
382
+ print('Set a task ID with: agdt-set background.task_id <task-id> or use --id "<task-id>"')
383
+ sys.exit(1)
384
+
385
+ # Allow state overrides for wait_interval and timeout
386
+ wait_interval = args.wait_interval
387
+ timeout = args.timeout
388
+
389
+ wait_interval_str = get_value("background.wait_interval")
390
+ if wait_interval_str:
391
+ try:
392
+ wait_interval = float(wait_interval_str)
393
+ except ValueError:
394
+ pass # Keep CLI arg value
395
+
396
+ timeout_str = get_value("background.timeout")
397
+ if timeout_str:
398
+ try:
399
+ timeout = float(timeout_str)
400
+ except ValueError:
401
+ pass # Keep CLI arg value
402
+
403
+ # Get task details
404
+ task = get_task_by_id(task_id)
405
+ if task is None:
406
+ print(f"Error: Task '{task_id}' not found.")
407
+ sys.exit(1)
408
+
409
+ print(f"Checking task (command: {task.command}, id: {task_id})...")
410
+
411
+ # Check 1: Is task already complete?
412
+ if task.is_terminal():
413
+ _handle_task_completed(task, task_id, timeout)
414
+ return
415
+
416
+ # Check timeout based on start_time
417
+ if _check_task_timeout(task, timeout):
418
+ _handle_task_timeout(task, task_id, timeout)
419
+ return
420
+
421
+ # Task still running - wait and check again
422
+ print(f"Task still running, waiting {wait_interval}s...")
423
+ time.sleep(wait_interval)
424
+
425
+ # Check 2: Re-fetch and check status
426
+ task = get_task_by_id(task_id)
427
+ if task is None:
428
+ print(f"Error: Task '{task_id}' disappeared during wait.")
429
+ sys.exit(1)
430
+
431
+ if task.is_terminal():
432
+ _handle_task_completed(task, task_id, timeout)
433
+ return
434
+
435
+ # Check timeout again
436
+ if _check_task_timeout(task, timeout):
437
+ _handle_task_timeout(task, task_id, timeout)
438
+ return
439
+
440
+ # Task still running after 2 checks - tell AI to wait again
441
+ _handle_task_still_running(task, task_id, wait_interval)
442
+
443
+
444
+ def _handle_task_still_running(task: BackgroundTask, task_id: str, wait_interval: float) -> None:
445
+ """Handle case where task is still running after checks."""
446
+ elapsed = _get_task_elapsed_time(task)
447
+
448
+ print()
449
+ print("=" * 70)
450
+ _safe_print("ā³ TASK STILL IN PROGRESS")
451
+ print("=" * 70)
452
+ _safe_print(f"\nTask: {task.command}")
453
+ _safe_print(f"Status: {_status_indicator(task.status)} {task.status.value}")
454
+ if elapsed is not None:
455
+ print(f"Running for: {elapsed:.1f}s")
456
+
457
+ _safe_print("\nšŸ“‹ Next Step:")
458
+ print(" Run agdt-task-wait again to continue waiting for completion.")
459
+ print()
460
+ print("Alternative actions:")
461
+ print(" • Check status: agdt-task-status")
462
+ print(" • View log: agdt-task-log")
463
+ sys.exit(0)
464
+
465
+
466
+ def _handle_task_timeout(task: BackgroundTask, task_id: str, timeout: float) -> None:
467
+ """Handle case where task has exceeded timeout."""
468
+ elapsed = _get_task_elapsed_time(task)
469
+
470
+ print()
471
+ print("=" * 70)
472
+ _safe_print("ā° TASK TIMEOUT")
473
+ print("=" * 70)
474
+ _safe_print(f"\nTask: {task.command}")
475
+ _safe_print(f"Status: {_status_indicator(task.status)} {task.status.value}")
476
+ if elapsed is not None:
477
+ print(f"Running for: {elapsed:.1f}s (timeout: {timeout}s)")
478
+
479
+ _safe_print("\nšŸ“‹ Next Steps:")
480
+ print(" 1. Check if task is stuck: agdt-task-log")
481
+ print(" 2. If stuck, consider canceling and retrying")
482
+ print(f" 3. Or increase timeout: agdt-set background.timeout {int(timeout * 2)}")
483
+ sys.exit(2)
484
+
485
+
486
+ def _get_task_elapsed_time(task: BackgroundTask) -> Optional[float]:
487
+ """Get elapsed time in seconds since task started."""
488
+ if not task.start_time:
489
+ return None
490
+ try:
491
+ start_dt = datetime.fromisoformat(task.start_time.replace("Z", "+00:00"))
492
+ return (datetime.now(timezone.utc) - start_dt).total_seconds()
493
+ except (ValueError, TypeError):
494
+ return None
495
+
496
+
497
+ def _try_advance_pr_review_to_summary() -> bool:
498
+ """
499
+ Check if pull-request-review workflow should auto-advance to summary step.
500
+
501
+ This function checks if:
502
+ 1. Current workflow is pull-request-review
503
+ 2. Current step is file-review
504
+ 3. All files have been reviewed (queue all_complete is True)
505
+
506
+ If all conditions are met:
507
+ - Advances workflow to summary step
508
+ - Triggers generate_pr_summary_async() as background task
509
+ - Sets background.task_id to the new summary task
510
+ - Prints message instructing AI to run agdt-task-wait
511
+
512
+ Returns:
513
+ True if workflow was advanced and summary task started
514
+ False if conditions not met or workflow not applicable
515
+ """
516
+ from ...state import get_value, get_workflow_state, set_value
517
+
518
+ # Check if we're in pull-request-review workflow at file-review step
519
+ workflow = get_workflow_state()
520
+ if not workflow:
521
+ return False
522
+
523
+ workflow_name = workflow.get("active", "")
524
+ current_step = workflow.get("step", "")
525
+
526
+ if workflow_name != "pull-request-review" or current_step != "file-review":
527
+ return False
528
+
529
+ # Get PR ID to check queue status
530
+ pr_id_str = get_value("pull_request_id")
531
+ if not pr_id_str:
532
+ return False
533
+
534
+ try:
535
+ pr_id = int(pr_id_str)
536
+ except ValueError:
537
+ return False
538
+
539
+ # Check if all files are complete
540
+ from ..azure_devops.file_review_commands import get_queue_status
541
+
542
+ queue_status = get_queue_status(pr_id)
543
+ if not queue_status.get("all_complete", False):
544
+ return False
545
+
546
+ # Also wait for all submissions to complete before generating summary
547
+ if queue_status.get("submission_pending_count", 0) > 0:
548
+ return False
549
+
550
+ # All conditions met - advance workflow to summary step
551
+ from ..workflows.base import set_workflow_state
552
+
553
+ context = workflow.get("context", {})
554
+ set_workflow_state(
555
+ name="pull-request-review",
556
+ status="in-progress",
557
+ step="summary",
558
+ context=context,
559
+ )
560
+
561
+ # Start summary generation as background task (without printing tracking info)
562
+ from ...background_tasks import run_function_in_background
563
+ from ...task_state import BackgroundTask
564
+
565
+ _PR_SUMMARY_MODULE = "agentic_devtools.cli.azure_devops.pr_summary_commands"
566
+ task: BackgroundTask = run_function_in_background(
567
+ _PR_SUMMARY_MODULE,
568
+ "generate_overarching_pr_comments_cli",
569
+ command_display_name="agdt-generate-pr-summary",
570
+ )
571
+
572
+ # Update background.task_id so next agdt-task-wait tracks this task
573
+ set_value("background.task_id", task.id)
574
+
575
+ # Print clear, unambiguous message for AI agent
576
+ print()
577
+ print("=" * 70)
578
+ _safe_print("āœ… ALL FILE REVIEWS COMPLETE - AUTO-GENERATING PR SUMMARY")
579
+ print("=" * 70)
580
+ print()
581
+ print("PR summary generation has been AUTOMATICALLY started in the background.")
582
+ print(f"Task ID: {task.id}")
583
+ print()
584
+ print("IMPORTANT: The summary will be posted automatically. Do NOT manually")
585
+ print("trigger dfly-generate-pr-summary - it is already running.")
586
+ print()
587
+ _safe_print("šŸ“‹ YOUR ONLY ACTION: Run agdt-task-wait")
588
+ print()
589
+ print("This will wait for the summary to complete and then provide next steps.")
590
+
591
+ return True
592
+
593
+
594
+ def _try_complete_pr_review_workflow(task: BackgroundTask) -> bool:
595
+ """
596
+ Check if pull-request-review workflow should complete after summary generation.
597
+
598
+ This function checks if:
599
+ 1. Current workflow is pull-request-review
600
+ 2. Current step is summary
601
+ 3. The completed task was dfly-generate-pr-summary
602
+
603
+ If all conditions are met:
604
+ - Advances workflow to completion step
605
+ - Prints completion message
606
+
607
+ Args:
608
+ task: The background task that just completed
609
+
610
+ Returns:
611
+ True if workflow was completed
612
+ False if conditions not met or workflow not applicable
613
+ """
614
+ from ...state import get_workflow_state
615
+
616
+ # Check if we're in pull-request-review workflow at summary step
617
+ workflow = get_workflow_state()
618
+ if not workflow:
619
+ return False
620
+
621
+ workflow_name = workflow.get("active", "")
622
+ current_step = workflow.get("step", "")
623
+
624
+ if workflow_name != "pull-request-review" or current_step != "summary":
625
+ return False
626
+
627
+ # Check if the completed task was the summary generation
628
+ if task.command != "agdt-generate-pr-summary":
629
+ return False
630
+
631
+ # All conditions met - advance workflow to completion
632
+ from ..workflows.base import set_workflow_state
633
+
634
+ context = workflow.get("context", {})
635
+ set_workflow_state(
636
+ name="pull-request-review",
637
+ status="completed",
638
+ step="completion",
639
+ context=context,
640
+ )
641
+
642
+ # Print completion message
643
+ print()
644
+ print("=" * 70)
645
+ _safe_print("āœ… PR REVIEW WORKFLOW COMPLETE")
646
+ print("=" * 70)
647
+ print()
648
+ print("All file reviews have been submitted and summary comments posted.")
649
+ print("The pull-request-review workflow has completed successfully.")
650
+ print()
651
+ print("NEXT STEPS:")
652
+ print(" 1. Review the PR summary comments that were posted")
653
+ print(" 2. Make a final decision on the PR:")
654
+ print(" - To approve: agdt-approve-pull-request")
655
+ print(" - Or provide feedback and request changes as needed")
656
+
657
+ return True
658
+
659
+
660
+ def _handle_task_completed(task: BackgroundTask, task_id: str, timeout: float) -> None:
661
+ """
662
+ Handle a completed task (success or failure).
663
+
664
+ This contains the original post-completion logic from task_wait.
665
+ """
666
+ from ...task_state import (
667
+ get_failed_most_recent_per_command,
668
+ get_incomplete_most_recent_per_command,
669
+ )
670
+
671
+ _safe_print(f"\nTask completed with status: {_status_indicator(task.status)} {task.status.value}")
672
+ print(f"Duration: {_format_duration(task)}")
673
+
674
+ if task.exit_code is not None:
675
+ print(f"Exit code: {task.exit_code}")
676
+
677
+ # Handle task failure - show log with fix instructions
678
+ if task.status == TaskStatus.FAILED:
679
+ print("\n" + "=" * 70)
680
+ _safe_print("āŒ TASK FAILED - Review log and fix the issue")
681
+ print("=" * 70)
682
+
683
+ if task.error_message:
684
+ print(f"\nError: {task.error_message}")
685
+
686
+ # Show the log
687
+ print("\n--- Task Log ---")
688
+ log_content = get_task_log_content(task_id)
689
+ if log_content:
690
+ print(log_content)
691
+ else:
692
+ print("(No log content available)")
693
+ print("--- End Log ---")
694
+
695
+ _safe_print("\nšŸ“‹ Next Steps:")
696
+ print(" 1. Review the error above")
697
+ print(" 2. Fix the underlying issue")
698
+ print(f" 3. Re-run the command: {task.command}")
699
+ print(" 4. Run agdt-task-wait again")
700
+ sys.exit(task.exit_code if task.exit_code is not None else 1)
701
+
702
+ # Task succeeded - now check for other tasks
703
+ print()
704
+
705
+ # Check for other incomplete most-recent-per-command tasks
706
+ other_incomplete = get_incomplete_most_recent_per_command(exclude_task_id=task_id)
707
+ if other_incomplete:
708
+ print("=" * 70)
709
+ _safe_print("ā³ OTHER TASKS STILL RUNNING")
710
+ print("=" * 70)
711
+ print(f"\n{len(other_incomplete)} other task(s) still in progress:")
712
+ for other_task in other_incomplete:
713
+ _safe_print(f" • {other_task.command} (id: {other_task.id}, status: {other_task.status.value})")
714
+
715
+ # Pick the first one to wait for
716
+ next_task = other_incomplete[0]
717
+ _safe_print("\nšŸ“‹ Next Step:")
718
+ print(f' agdt-task-wait --id "{next_task.id}"')
719
+ sys.exit(0)
720
+
721
+ # Check for failed most-recent-per-command tasks
722
+ # Exclude the current task's command since it just succeeded - we don't want to
723
+ # report an older failed task for the same command
724
+ failed_tasks = get_failed_most_recent_per_command(
725
+ exclude_task_id=task_id,
726
+ exclude_commands=[task.command],
727
+ )
728
+ if failed_tasks:
729
+ print("=" * 70)
730
+ _safe_print("āŒ OTHER TASKS FAILED - Review and fix")
731
+ print("=" * 70)
732
+ print(f"\n{len(failed_tasks)} task(s) failed:")
733
+ for failed_task in failed_tasks:
734
+ _safe_print(f" • {failed_task.command} (id: {failed_task.id})")
735
+
736
+ # Pick the first one to review
737
+ next_task = failed_tasks[0]
738
+ _safe_print("\nšŸ“‹ Next Steps:")
739
+ print(f' 1. Review the failed task: agdt-task-log --id "{next_task.id}"')
740
+ print(" 2. Fix the underlying issue")
741
+ print(f" 3. Re-run the command: {next_task.command}")
742
+ sys.exit(0)
743
+
744
+ # All tasks succeeded - check for special workflow handling
745
+ # Check if we need to auto-advance pull-request-review workflow
746
+ if _try_advance_pr_review_to_summary():
747
+ # Successfully advanced and triggered summary - AI agent should run agdt-task-wait
748
+ sys.exit(0)
749
+
750
+ # Check if pull-request-review workflow should complete after summary
751
+ if _try_complete_pr_review_workflow(task):
752
+ # Workflow completed successfully
753
+ sys.exit(0)
754
+
755
+ # Standard workflow progression
756
+ print("=" * 70)
757
+ _safe_print("āœ… ALL TASKS COMPLETED SUCCESSFULLY")
758
+ print("=" * 70)
759
+
760
+ # Try to get next workflow prompt
761
+ try:
762
+ from ..workflows import get_next_workflow_prompt_cmd
763
+
764
+ print("\nAuto-progressing workflow...\n")
765
+ get_next_workflow_prompt_cmd()
766
+ except ImportError:
767
+ # Workflow module not available
768
+ print("\nAll background tasks complete.")
769
+ except Exception as e:
770
+ # Workflow command failed - still report success
771
+ print(f"\nNote: Could not auto-progress workflow: {e}")
772
+ print("Run manually: agdt-get-next-workflow-prompt")
773
+
774
+
775
+ def tasks_clean() -> None:
776
+ """
777
+ Clean up expired tasks and old log files.
778
+
779
+ Entry point: agdt-tasks-clean
780
+ Optional state keys:
781
+ - background.expiry_hours: Hours before tasks expire (default: 24)
782
+ """
783
+ from ...background_tasks import cleanup_old_logs
784
+ from ...state import load_state
785
+
786
+ state = load_state()
787
+
788
+ # Parse optional expiry hours
789
+ expiry_hours = 24 # Default 24 hours
790
+
791
+ expiry_str = state.get("background.expiry_hours")
792
+ if expiry_str:
793
+ try:
794
+ expiry_hours = int(expiry_str)
795
+ except ValueError:
796
+ print(f"Warning: Invalid expiry hours '{expiry_str}', using default {expiry_hours}h")
797
+
798
+ print(f"Cleaning up tasks older than {expiry_hours} hours...")
799
+
800
+ # Get task count before cleanup
801
+ tasks_before = get_background_tasks()
802
+ count_before = len(tasks_before)
803
+
804
+ # Clean up expired tasks
805
+ removed_count = cleanup_expired_tasks(retention_hours=expiry_hours)
806
+
807
+ # Clean up orphaned log files
808
+ logs_removed = cleanup_old_logs(max_age_hours=expiry_hours)
809
+
810
+ print("\nCleanup complete:")
811
+ print(f" Tasks before: {count_before}")
812
+ print(f" Tasks removed: {removed_count}")
813
+ print(f" Tasks remaining: {count_before - removed_count}")
814
+ print(f" Log files removed: {logs_removed}")
815
+
816
+
817
+ def show_other_incomplete_tasks() -> None:
818
+ """
819
+ Show other incomplete background tasks (not the current task_id).
820
+
821
+ Entry point: agdt-show-other-incomplete-tasks
822
+
823
+ This command shows any tasks in background.recentTasks that:
824
+ - Are not in status 'completed' or 'failed'
825
+ - Do not match the current background.task_id
826
+
827
+ Useful for seeing if there are other tasks still running.
828
+ """
829
+ from ...state import get_value
830
+
831
+ current_task_id = get_value("background.task_id") or ""
832
+
833
+ other_incomplete = get_other_incomplete_tasks(current_task_id)
834
+
835
+ if not other_incomplete:
836
+ print("No other recent incomplete background tasks.")
837
+ return
838
+
839
+ print(f"\nOther incomplete background tasks ({len(other_incomplete)} found):")
840
+ print(f"{'ID':<36} {'Status':<12} {'Command':<30} {'Duration':<10}")
841
+ print("-" * 95)
842
+
843
+ for task in other_incomplete:
844
+ status_str = f"{_status_indicator(task.status)} {task.status.value}"
845
+ command_display = task.command[:27] + "..." if len(task.command) > 30 else task.command
846
+ duration = _format_duration(task)
847
+
848
+ _safe_print(f"{task.id:<36} {status_str:<12} {command_display:<30} {duration:<10}")
849
+
850
+ print()
851
+ print('To track a specific task: agdt-task-status --id "<task-id>"')