aidp 0.23.0 → 0.24.0

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/lib/aidp/cli.rb +3 -0
  3. data/lib/aidp/execute/work_loop_runner.rb +252 -45
  4. data/lib/aidp/execute/work_loop_unit_scheduler.rb +27 -2
  5. data/lib/aidp/harness/condition_detector.rb +42 -8
  6. data/lib/aidp/harness/config_manager.rb +7 -0
  7. data/lib/aidp/harness/config_schema.rb +25 -0
  8. data/lib/aidp/harness/configuration.rb +69 -6
  9. data/lib/aidp/harness/error_handler.rb +117 -44
  10. data/lib/aidp/harness/provider_manager.rb +64 -0
  11. data/lib/aidp/harness/provider_metrics.rb +138 -0
  12. data/lib/aidp/harness/runner.rb +90 -29
  13. data/lib/aidp/harness/simple_user_interface.rb +4 -0
  14. data/lib/aidp/harness/state/ui_state.rb +0 -10
  15. data/lib/aidp/harness/state_manager.rb +1 -15
  16. data/lib/aidp/harness/test_runner.rb +39 -2
  17. data/lib/aidp/logger.rb +34 -4
  18. data/lib/aidp/providers/adapter.rb +241 -0
  19. data/lib/aidp/providers/anthropic.rb +75 -7
  20. data/lib/aidp/providers/base.rb +29 -1
  21. data/lib/aidp/providers/capability_registry.rb +205 -0
  22. data/lib/aidp/providers/codex.rb +14 -0
  23. data/lib/aidp/providers/error_taxonomy.rb +195 -0
  24. data/lib/aidp/providers/gemini.rb +3 -2
  25. data/lib/aidp/setup/provider_registry.rb +107 -0
  26. data/lib/aidp/setup/wizard.rb +115 -31
  27. data/lib/aidp/version.rb +1 -1
  28. data/lib/aidp/watch/build_processor.rb +263 -23
  29. data/lib/aidp/watch/repository_client.rb +4 -4
  30. data/lib/aidp/watch/runner.rb +37 -5
  31. data/lib/aidp/workflows/guided_agent.rb +53 -0
  32. data/lib/aidp/worktree.rb +67 -10
  33. data/templates/work_loop/decide_whats_next.md +21 -0
  34. data/templates/work_loop/diagnose_failures.md +21 -0
  35. metadata +10 -3
  36. /data/{bin → exe}/aidp +0 -0
@@ -2,11 +2,14 @@
2
2
 
3
3
  require "open3"
4
4
  require "time"
5
+ require "fileutils"
5
6
 
6
7
  require_relative "../message_display"
7
8
  require_relative "../execute/prompt_manager"
8
9
  require_relative "../harness/runner"
10
+ require_relative "../harness/state_manager"
9
11
  require_relative "../worktree"
12
+ require_relative "../execute/progress"
10
13
 
11
14
  module Aidp
12
15
  module Watch
@@ -55,6 +58,8 @@ module Aidp
55
58
  working_dir = @project_dir
56
59
  end
57
60
 
61
+ sync_local_aidp_config(working_dir)
62
+
58
63
  prompt_content = build_prompt(issue: issue, plan_data: plan_data)
59
64
  write_prompt(prompt_content, working_dir: working_dir)
60
65
 
@@ -65,14 +70,36 @@ module Aidp
65
70
  handle_success(issue: issue, slug: slug, branch_name: branch_name, base_branch: base_branch, plan_data: plan_data, working_dir: working_dir)
66
71
  elsif result[:status] == "needs_clarification"
67
72
  handle_clarification_request(issue: issue, slug: slug, result: result)
73
+ elsif result[:reason] == :completion_criteria
74
+ handle_incomplete_criteria(issue: issue, slug: slug, branch_name: branch_name, working_dir: working_dir, metadata: result[:failure_metadata])
68
75
  else
69
76
  handle_failure(issue: issue, slug: slug, result: result)
70
77
  end
71
78
  rescue => e
72
- display_message("❌ Implementation failed: #{e.message}", type: :error)
73
- @state_store.record_build_status(issue[:number], status: "failed", details: {error: e.message})
74
- cleanup_workstream(slug) if @use_workstreams && slug
75
- raise
79
+ # Don't re-raise - handle gracefully for fix-forward pattern
80
+ display_message("❌ Implementation failed with exception: #{e.message}", type: :error)
81
+ Aidp.log_error(
82
+ "build_processor",
83
+ "Implementation failed with exception",
84
+ issue: issue[:number],
85
+ error: e.message,
86
+ error_class: e.class.name,
87
+ backtrace: e.backtrace&.first(10)
88
+ )
89
+
90
+ # Create error result to pass to handle_failure
91
+ error_result = {
92
+ status: "error",
93
+ error: e.message,
94
+ error_class: e.class.name,
95
+ message: "Exception during harness execution: #{e.message}"
96
+ }
97
+
98
+ # Handle as failure (posts comment, updates state) but DON'T re-raise
99
+ handle_failure(issue: issue, slug: slug, result: error_result)
100
+
101
+ # Note: We intentionally DON'T re-raise here to allow watch mode to continue
102
+ # The error has been logged, recorded, and reported to GitHub
76
103
  end
77
104
 
78
105
  private
@@ -147,20 +174,18 @@ module Aidp
147
174
  base_branch: base_branch
148
175
  )
149
176
 
150
- if result[:success]
151
- display_message("✅ Workstream created at #{result[:path]}", type: :success)
152
- result[:path]
153
- else
154
- raise "Failed to create workstream: #{result[:message]}"
155
- end
177
+ worktree_path = worktree_path_from_result(result)
178
+ display_message("✅ Workstream created at #{worktree_path}", type: :success)
179
+ worktree_path
156
180
  end
157
181
 
158
182
  def cleanup_workstream(slug)
159
183
  return unless slug
160
184
 
161
185
  display_message("🧹 Cleaning up workstream: #{slug}", type: :info)
162
- result = Aidp::Worktree.remove(slug: slug, project_dir: @project_dir, force: true)
163
- if result[:success]
186
+ result = Aidp::Worktree.remove(slug: slug, project_dir: @project_dir, delete_branch: true)
187
+ removed = (result == true) || (result.respond_to?(:[]) && result[:success])
188
+ if removed
164
189
  display_message("✅ Workstream removed", type: :success)
165
190
  else
166
191
  display_message("⚠️ Failed to remove workstream: #{result[:message]}", type: :warn)
@@ -211,7 +236,7 @@ module Aidp
211
236
 
212
237
  def write_prompt(content, working_dir: @project_dir)
213
238
  prompt_manager = Aidp::Execute::PromptManager.new(working_dir)
214
- prompt_manager.write(content)
239
+ prompt_manager.write(content, step_name: IMPLEMENTATION_STEP)
215
240
  display_message("📝 Wrote PROMPT.md with implementation contract", type: :info)
216
241
 
217
242
  if @verbose
@@ -243,10 +268,21 @@ module Aidp
243
268
  end
244
269
 
245
270
  def run_harness(user_input:, working_dir: @project_dir)
271
+ reset_work_loop_state(working_dir)
272
+
273
+ Aidp.log_info(
274
+ "build_processor",
275
+ "starting_harness",
276
+ issue_dir: working_dir,
277
+ workflow_type: :watch_mode,
278
+ selected_steps: [IMPLEMENTATION_STEP]
279
+ )
280
+
246
281
  options = {
247
282
  selected_steps: [IMPLEMENTATION_STEP],
248
283
  workflow_type: :watch_mode,
249
- user_input: user_input
284
+ user_input: user_input,
285
+ non_interactive: true
250
286
  }
251
287
 
252
288
  display_message("🚀 Running harness in execute mode...", type: :info) if @verbose
@@ -258,20 +294,116 @@ module Aidp
258
294
  display_message("\n--- Harness Result ---", type: :muted)
259
295
  display_message("Status: #{result[:status]}", type: :muted)
260
296
  display_message("Message: #{result[:message]}", type: :muted) if result[:message]
297
+ if result[:error]
298
+ display_message("Error: #{result[:error]}", type: :muted)
299
+ display_message("Error Details: #{result[:error_details]}", type: :muted) if result[:error_details]
300
+ end
261
301
  display_message("--- End Result ---\n", type: :muted)
262
302
  end
263
303
 
304
+ Aidp.log_info(
305
+ "build_processor",
306
+ "harness_result",
307
+ status: result[:status],
308
+ message: result[:message],
309
+ error: result[:error],
310
+ error_class: result[:error_class]
311
+ )
312
+
313
+ # Log errors to aidp.log
314
+ if result[:status] == "error"
315
+ error_msg = result[:message] || "Unknown error"
316
+ error_details = {
317
+ status: result[:status],
318
+ message: error_msg,
319
+ error: result[:error]&.to_s,
320
+ error_class: result[:error]&.class&.name,
321
+ backtrace: result[:backtrace]&.first(5)
322
+ }.compact
323
+ Aidp.log_error("build_processor", "Harness execution failed", **error_details)
324
+ end
325
+
264
326
  result
265
327
  end
266
328
 
329
+ def reset_work_loop_state(working_dir)
330
+ state_manager = Aidp::Harness::StateManager.new(working_dir, :execute)
331
+ state_manager.clear_state
332
+ Aidp::Execute::Progress.new(working_dir).reset
333
+ rescue => e
334
+ display_message("⚠️ Failed to reset work loop state before execution: #{e.message}", type: :warn)
335
+ Aidp.log_warn("build_processor", "failed_to_reset_work_loop_state", error: e.message, working_dir: working_dir)
336
+ end
337
+
338
+ def enqueue_decider_followup(target_dir)
339
+ work_loop_dir = File.join(target_dir, ".aidp", "work_loop")
340
+ FileUtils.mkdir_p(work_loop_dir)
341
+ request_path = File.join(work_loop_dir, "initial_units.txt")
342
+ File.open(request_path, "a") { |file| file.puts("decide_whats_next") }
343
+ Aidp.log_info("build_processor", "scheduled_decider_followup", request_path: request_path)
344
+ rescue => e
345
+ Aidp.log_warn("build_processor", "failed_to_schedule_decider", error: e.message)
346
+ end
347
+
348
+ def sync_local_aidp_config(target_dir)
349
+ return if target_dir.nil? || target_dir == @project_dir
350
+
351
+ source_config = File.join(@project_dir, ".aidp", "aidp.yml")
352
+ return unless File.exist?(source_config)
353
+
354
+ target_config = File.join(target_dir, ".aidp", "aidp.yml")
355
+ FileUtils.mkdir_p(File.dirname(target_config))
356
+
357
+ # Only copy when target missing or differs
358
+ if !File.exist?(target_config) || File.read(source_config) != File.read(target_config)
359
+ FileUtils.cp(source_config, target_config)
360
+ end
361
+ rescue => e
362
+ display_message("⚠️ Failed to sync AIDP config to workstream: #{e.message}", type: :warn)
363
+ end
364
+
365
+ def worktree_path_from_result(result)
366
+ return result if result.is_a?(String)
367
+
368
+ path = result[:path] || result["path"]
369
+ return path if path
370
+
371
+ message = result[:message] || "unknown error"
372
+ raise "Failed to create workstream: #{message}"
373
+ end
374
+
267
375
  def handle_success(issue:, slug:, branch_name:, base_branch:, plan_data:, working_dir:)
268
- stage_and_commit(issue, working_dir: working_dir)
376
+ changes_committed = stage_and_commit(issue, working_dir: working_dir)
377
+
378
+ unless changes_committed
379
+ handle_no_changes(issue: issue, slug: slug, branch_name: branch_name, working_dir: working_dir)
380
+ return
381
+ end
269
382
 
270
383
  # Check if PR should be created based on VCS preferences
384
+ # For watch mode, default to creating PRs (set to false to disable)
271
385
  vcs_config = config.dig(:work_loop, :version_control) || {}
272
- auto_create_pr = vcs_config.fetch(:auto_create_pr, false)
273
-
274
- pr_url = if auto_create_pr
386
+ auto_create_pr = vcs_config.fetch(:auto_create_pr, true)
387
+
388
+ pr_url = if !changes_committed
389
+ Aidp.log_info(
390
+ "build_processor",
391
+ "skipping_pr_no_commits",
392
+ issue: issue[:number],
393
+ branch: branch_name,
394
+ working_dir: working_dir
395
+ )
396
+ display_message("ℹ️ Skipping PR creation because there are no commits on #{branch_name}.", type: :muted)
397
+ nil
398
+ elsif auto_create_pr
399
+ Aidp.log_info(
400
+ "build_processor",
401
+ "creating_pull_request",
402
+ issue: issue[:number],
403
+ branch: branch_name,
404
+ base_branch: base_branch,
405
+ working_dir: working_dir
406
+ )
275
407
  create_pull_request(issue: issue, branch_name: branch_name, base_branch: base_branch, working_dir: working_dir)
276
408
  else
277
409
  display_message("ℹ️ Skipping PR creation (disabled in VCS preferences)", type: :muted)
@@ -355,37 +487,131 @@ module Aidp
355
487
 
356
488
  def handle_failure(issue:, slug:, result:)
357
489
  message = result[:message] || "Unknown failure"
490
+ error_info = result[:error] || result[:error_details]
358
491
  workstream_note = @use_workstreams ? " The workstream `#{slug}` has been left intact for debugging." : " The branch has been left intact for debugging."
492
+
493
+ # Build detailed error message for the comment
494
+ error_details_section = if error_info
495
+ "\nError: #{error_info}"
496
+ else
497
+ ""
498
+ end
499
+
359
500
  comment = <<~COMMENT
360
501
  ❌ Implementation attempt for ##{issue[:number]} failed.
361
502
 
362
503
  Status: #{result[:status]}
363
- Details: #{message}
504
+ Details: #{message}#{error_details_section}
364
505
 
365
506
  Please review the repository for partial changes.#{workstream_note}
366
507
  COMMENT
367
508
  @repository_client.post_comment(issue[:number], comment)
509
+
510
+ # Log the failure with full details
511
+ Aidp.log_error(
512
+ "build_processor",
513
+ "Build failed for issue ##{issue[:number]}",
514
+ status: result[:status],
515
+ message: message,
516
+ error: error_info&.to_s,
517
+ workstream: slug
518
+ )
519
+
368
520
  @state_store.record_build_status(
369
521
  issue[:number],
370
522
  status: "failed",
371
- details: {message: message, workstream: slug}
523
+ details: {message: message, error: error_info&.to_s, workstream: slug}
372
524
  )
373
525
  display_message("⚠️ Build failure recorded for issue ##{issue[:number]}", type: :warn)
374
526
  end
375
527
 
528
+ def handle_no_changes(issue:, slug:, branch_name:, working_dir:)
529
+ location_note = if @use_workstreams
530
+ "The workstream `#{slug}` has been preserved for review."
531
+ else
532
+ "Branch `#{branch_name}` remains checked out for inspection."
533
+ end
534
+
535
+ @state_store.record_build_status(
536
+ issue[:number],
537
+ status: "no_changes",
538
+ details: {branch: branch_name, workstream: slug}
539
+ )
540
+
541
+ Aidp.log_warn(
542
+ "build_processor",
543
+ "noop_build_result",
544
+ issue: issue[:number],
545
+ branch: branch_name,
546
+ workstream: slug
547
+ )
548
+
549
+ display_message("⚠️ Implementation produced no changes; labels remain untouched. #{location_note}", type: :warn)
550
+ enqueue_decider_followup(working_dir)
551
+ end
552
+
553
+ def handle_incomplete_criteria(issue:, slug:, branch_name:, working_dir:, metadata:)
554
+ display_message("⚠️ Completion criteria unmet; scheduling additional fix-forward iteration.", type: :warn)
555
+ enqueue_decider_followup(working_dir)
556
+
557
+ @state_store.record_build_status(
558
+ issue[:number],
559
+ status: "pending_fix_forward",
560
+ details: {branch: branch_name, workstream: slug, criteria: metadata}
561
+ )
562
+
563
+ Aidp.log_info(
564
+ "build_processor",
565
+ "pending_fix_forward",
566
+ issue: issue[:number],
567
+ branch: branch_name,
568
+ workstream: slug,
569
+ criteria: metadata
570
+ )
571
+ end
572
+
376
573
  def stage_and_commit(issue, working_dir: @project_dir)
574
+ commit_created = false
575
+
377
576
  Dir.chdir(working_dir) do
378
577
  status_output = run_git(%w[status --porcelain])
379
578
  if status_output.strip.empty?
380
579
  display_message("ℹ️ No file changes detected after work loop.", type: :muted)
381
- return
580
+ Aidp.log_info("build_processor", "no_changes_after_work_loop", issue: issue[:number], working_dir: working_dir)
581
+ return commit_created
382
582
  end
383
583
 
584
+ changed_entries = status_output.lines.map(&:strip).reject(&:empty?)
585
+ Aidp.log_info(
586
+ "build_processor",
587
+ "changes_detected_after_work_loop",
588
+ issue: issue[:number],
589
+ working_dir: working_dir,
590
+ changed_file_count: changed_entries.length,
591
+ changed_files_sample: changed_entries.first(10)
592
+ )
593
+
384
594
  run_git(%w[add -A])
385
595
  commit_message = build_commit_message(issue)
386
596
  run_git(["commit", "-m", commit_message])
387
597
  display_message("💾 Created commit: #{commit_message.lines.first.strip}", type: :info)
598
+ Aidp.log_info(
599
+ "build_processor",
600
+ "commit_created",
601
+ working_dir: working_dir,
602
+ issue: issue[:number],
603
+ commit_summary: commit_message.lines.first.strip
604
+ )
605
+
606
+ # Push the branch to remote
607
+ current_branch = run_git(%w[branch --show-current]).strip
608
+ run_git(["push", "-u", "origin", current_branch])
609
+ display_message("⬆️ Pushed branch '#{current_branch}' to remote", type: :info)
610
+ Aidp.log_info("build_processor", "branch_pushed", branch: current_branch, working_dir: working_dir)
611
+ commit_created = true
388
612
  end
613
+
614
+ commit_created
389
615
  end
390
616
 
391
617
  def build_commit_message(issue)
@@ -443,6 +669,7 @@ module Aidp
443
669
  body = <<~BODY
444
670
  ## Summary
445
671
  - Automated resolution for ##{issue[:number]}
672
+ - Fixes ##{issue[:number]}
446
673
 
447
674
  ## Testing
448
675
  #{test_summary}
@@ -453,16 +680,29 @@ module Aidp
453
680
  pr_strategy = vcs_config[:pr_strategy] || "draft"
454
681
  draft = (pr_strategy == "draft")
455
682
 
683
+ # Assign PR to the issue author
684
+ assignee = issue[:author]
685
+
456
686
  output = @repository_client.create_pull_request(
457
687
  title: title,
458
688
  body: body,
459
689
  head: branch_name,
460
690
  base: base_branch,
461
691
  issue_number: issue[:number],
462
- draft: draft
692
+ draft: draft,
693
+ assignee: assignee
463
694
  )
464
695
 
465
- extract_pr_url(output)
696
+ pr_url = extract_pr_url(output)
697
+ Aidp.log_info(
698
+ "build_processor",
699
+ "pull_request_created",
700
+ issue: issue[:number],
701
+ branch: branch_name,
702
+ base_branch: base_branch,
703
+ pr_url: pr_url
704
+ )
705
+ pr_url
466
706
  end
467
707
 
468
708
  def gather_test_summary(working_dir: @project_dir)
@@ -61,8 +61,8 @@ module Aidp
61
61
  gh_available? ? post_comment_via_gh(number, body) : post_comment_via_api(number, body)
62
62
  end
63
63
 
64
- def create_pull_request(title:, body:, head:, base:, issue_number:)
65
- gh_available? ? create_pull_request_via_gh(title: title, body: body, head: head, base: base, issue_number: issue_number) : raise("GitHub CLI not available - cannot create PR")
64
+ def create_pull_request(title:, body:, head:, base:, issue_number:, draft: false, assignee: nil)
65
+ gh_available? ? create_pull_request_via_gh(title: title, body: body, head: head, base: base, issue_number: issue_number, draft: draft, assignee: assignee) : raise("GitHub CLI not available - cannot create PR")
66
66
  end
67
67
 
68
68
  def add_labels(number, *labels)
@@ -176,7 +176,7 @@ module Aidp
176
176
  response.body
177
177
  end
178
178
 
179
- def create_pull_request_via_gh(title:, body:, head:, base:, issue_number:, draft: false)
179
+ def create_pull_request_via_gh(title:, body:, head:, base:, issue_number:, draft: false, assignee: nil)
180
180
  cmd = [
181
181
  "gh", "pr", "create",
182
182
  "--repo", full_repo,
@@ -185,8 +185,8 @@ module Aidp
185
185
  "--head", head,
186
186
  "--base", base
187
187
  ]
188
- cmd += ["--issue", issue_number.to_s] if issue_number
189
188
  cmd += ["--draft"] if draft
189
+ cmd += ["--assignee", assignee] if assignee
190
190
 
191
191
  stdout, stderr, status = Open3.capture3(*cmd)
192
192
  raise "Failed to create PR via gh: #{stderr.strip}" unless status.success?
@@ -58,12 +58,25 @@ module Aidp
58
58
  # Validate safety requirements before starting
59
59
  @safety_checker.validate_watch_mode_safety!(force: @force)
60
60
 
61
+ Aidp.log_info(
62
+ "watch_runner",
63
+ "watch_mode_started",
64
+ repo: @repository_client.full_repo,
65
+ interval: @interval,
66
+ once: @once,
67
+ use_workstreams: @use_workstreams,
68
+ verbose: @verbose
69
+ )
70
+
61
71
  display_message("👀 Watch mode enabled for #{@repository_client.full_repo}", type: :highlight)
62
72
  display_message("Polling every #{@interval} seconds. Press Ctrl+C to stop.", type: :muted)
63
73
 
64
74
  loop do
75
+ Aidp.log_debug("watch_runner", "poll_cycle.begin", repo: @repository_client.full_repo, interval: @interval)
65
76
  process_cycle
77
+ Aidp.log_debug("watch_runner", "poll_cycle.complete", once: @once, next_poll_in: @once ? nil : @interval)
66
78
  break if @once
79
+ Aidp.log_debug("watch_runner", "poll_cycle.sleep", seconds: @interval)
67
80
  sleep @interval
68
81
  end
69
82
  rescue Interrupt
@@ -83,14 +96,22 @@ module Aidp
83
96
  def process_plan_triggers
84
97
  plan_label = @plan_processor.plan_label
85
98
  issues = @repository_client.list_issues(labels: [plan_label], state: "open")
99
+ Aidp.log_debug("watch_runner", "plan_poll", label: plan_label, total: issues.size)
86
100
  issues.each do |issue|
87
- next unless issue_has_label?(issue, plan_label)
101
+ unless issue_has_label?(issue, plan_label)
102
+ Aidp.log_debug("watch_runner", "plan_skip_label_mismatch", issue: issue[:number], labels: issue[:labels])
103
+ next
104
+ end
88
105
 
89
106
  detailed = @repository_client.fetch_issue(issue[:number])
90
107
 
91
108
  # Check author authorization before processing
92
- next unless @safety_checker.should_process_issue?(detailed, enforce: false)
109
+ unless @safety_checker.should_process_issue?(detailed, enforce: false)
110
+ Aidp.log_debug("watch_runner", "plan_skip_unauthorized_author", issue: detailed[:number], author: detailed[:author])
111
+ next
112
+ end
93
113
 
114
+ Aidp.log_debug("watch_runner", "plan_process", issue: detailed[:number])
94
115
  @plan_processor.process(detailed)
95
116
  rescue RepositorySafetyChecker::UnauthorizedAuthorError => e
96
117
  Aidp.log_warn("watch_runner", "unauthorized issue author", issue: issue[:number], error: e.message)
@@ -100,17 +121,28 @@ module Aidp
100
121
  def process_build_triggers
101
122
  build_label = @build_processor.build_label
102
123
  issues = @repository_client.list_issues(labels: [build_label], state: "open")
124
+ Aidp.log_debug("watch_runner", "build_poll", label: build_label, total: issues.size)
103
125
  issues.each do |issue|
104
- next unless issue_has_label?(issue, build_label)
126
+ unless issue_has_label?(issue, build_label)
127
+ Aidp.log_debug("watch_runner", "build_skip_label_mismatch", issue: issue[:number], labels: issue[:labels])
128
+ next
129
+ end
105
130
 
106
131
  status = @state_store.build_status(issue[:number])
107
- next if status["status"] == "completed"
132
+ if status["status"] == "completed"
133
+ Aidp.log_debug("watch_runner", "build_skip_completed", issue: issue[:number])
134
+ next
135
+ end
108
136
 
109
137
  detailed = @repository_client.fetch_issue(issue[:number])
110
138
 
111
139
  # Check author authorization before processing
112
- next unless @safety_checker.should_process_issue?(detailed, enforce: false)
140
+ unless @safety_checker.should_process_issue?(detailed, enforce: false)
141
+ Aidp.log_debug("watch_runner", "build_skip_unauthorized_author", issue: detailed[:number], author: detailed[:author])
142
+ next
143
+ end
113
144
 
145
+ Aidp.log_debug("watch_runner", "build_process", issue: detailed[:number])
114
146
  @build_processor.process(detailed)
115
147
  rescue RepositorySafetyChecker::UnauthorizedAuthorError => e
116
148
  Aidp.log_warn("watch_runner", "unauthorized issue author", issue: issue[:number], error: e.message)
@@ -229,16 +229,34 @@ module Aidp
229
229
  end
230
230
 
231
231
  combined_prompt = "#{system_prompt}\n\n#{user_prompt}"
232
+
233
+ # Record timing for metrics
234
+ start_time = Time.now
232
235
  result = provider.send_message(prompt: combined_prompt)
236
+ duration = Time.now - start_time
233
237
 
234
238
  if result.nil? || result.empty?
239
+ # Record failed metric
240
+ @provider_manager.record_metrics(provider_name, success: false, duration: duration, error: StandardError.new("Empty response")) if @provider_manager.respond_to?(:record_metrics)
235
241
  raise ConversationError, "Provider request failed: empty response"
236
242
  end
237
243
 
244
+ # Record successful metric
245
+ @provider_manager.record_metrics(provider_name, success: true, duration: duration) if @provider_manager.respond_to?(:record_metrics)
246
+
238
247
  result
239
248
  rescue => e
240
249
  message = e.message.to_s
241
250
 
251
+ # Record failed metric (if we haven't already from empty response check)
252
+ if defined?(start_time) && defined?(provider_name) && defined?(duration).nil?
253
+ duration = Time.now - start_time
254
+ @provider_manager.record_metrics(provider_name, success: false, duration: duration, error: e) if @provider_manager.respond_to?(:record_metrics)
255
+ end
256
+
257
+ # Extract rate limit reset time from error message (especially Claude)
258
+ extract_and_save_rate_limit_info(provider_name, message) if defined?(provider_name)
259
+
242
260
  # Classify error type for better handling
243
261
  classified = if message =~ /resource[_ ]exhausted/i || message =~ /\[resource_exhausted\]/i
244
262
  "resource_exhausted"
@@ -319,6 +337,41 @@ module Aidp
319
337
  Aidp.logger.warn("guided_agent", "Failed verbose iteration emit", error: e.message)
320
338
  end
321
339
 
340
+ # Extract rate limit reset time from error message and save to provider manager
341
+ def extract_and_save_rate_limit_info(provider_name, error_message)
342
+ return unless @provider_manager.respond_to?(:mark_rate_limited)
343
+ return unless error_message
344
+
345
+ # Claude error format: "please try again in 1m23s" or "retry after X seconds/minutes"
346
+ reset_time = nil
347
+
348
+ if error_message =~ /try again in (\d+)m(\d+)s/i
349
+ minutes = $1.to_i
350
+ seconds = $2.to_i
351
+ reset_time = Time.now + (minutes * 60) + seconds
352
+ elsif error_message =~ /try again in (\d+)s/i
353
+ seconds = $1.to_i
354
+ reset_time = Time.now + seconds
355
+ elsif error_message =~ /try again in (\d+)m/i
356
+ minutes = $1.to_i
357
+ reset_time = Time.now + (minutes * 60)
358
+ elsif error_message =~ /retry after (\d+) seconds?/i
359
+ seconds = $1.to_i
360
+ reset_time = Time.now + seconds
361
+ elsif error_message =~ /retry after (\d+) minutes?/i
362
+ minutes = $1.to_i
363
+ reset_time = Time.now + (minutes * 60)
364
+ end
365
+
366
+ # Mark provider as rate limited with extracted reset time
367
+ if reset_time
368
+ @provider_manager.mark_rate_limited(provider_name, reset_time)
369
+ Aidp.logger.info("guided_agent", "Extracted rate limit reset time", provider: provider_name, reset_at: reset_time.iso8601)
370
+ end
371
+ rescue => e
372
+ Aidp.logger.warn("guided_agent", "Failed to extract rate limit info", error: e.message)
373
+ end
374
+
322
375
  def validate_provider_configuration!
323
376
  configured = @provider_manager.configured_providers
324
377
  if configured.nil? || configured.empty?