aidp 0.17.0 → 0.17.1

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.
@@ -11,19 +11,20 @@ module Aidp
11
11
  module Harness
12
12
  # Manages harness-specific state and persistence, extending existing progress tracking
13
13
  class StateManager
14
- def initialize(project_dir, mode)
14
+ def initialize(project_dir, mode, skip_persistence: false)
15
15
  @project_dir = project_dir
16
16
  @mode = mode
17
17
  @state_dir = File.join(project_dir, ".aidp", "harness")
18
18
  @state_file = File.join(@state_dir, "#{mode}_state.json")
19
19
  @lock_file = File.join(@state_dir, "#{mode}_state.lock")
20
+ @skip_persistence = skip_persistence
21
+ @memory_state = {} if @skip_persistence # In-memory state for tests
20
22
 
21
- # Initialize the appropriate progress tracker
22
23
  case mode
23
24
  when :analyze
24
- @progress_tracker = Aidp::Analyze::Progress.new(project_dir)
25
+ @progress_tracker = Aidp::Analyze::Progress.new(project_dir, skip_persistence: @skip_persistence)
25
26
  when :execute
26
- @progress_tracker = Aidp::Execute::Progress.new(project_dir)
27
+ @progress_tracker = Aidp::Execute::Progress.new(project_dir, skip_persistence: @skip_persistence)
27
28
  else
28
29
  raise ArgumentError, "Unsupported mode: #{mode}"
29
30
  end
@@ -33,21 +34,14 @@ module Aidp
33
34
 
34
35
  # Check if state exists
35
36
  def has_state?
36
- # In test mode, always return false to avoid file operations
37
- return false if ENV["RACK_ENV"] == "test" || defined?(RSpec)
38
-
37
+ return false if @skip_persistence
39
38
  File.exist?(@state_file)
40
39
  end
41
40
 
42
41
  # Load existing state
43
42
  def load_state
44
- # In test mode, return empty state to avoid file locking issues
45
- if ENV["RACK_ENV"] == "test" || defined?(RSpec)
46
- return {}
47
- end
48
-
43
+ return @memory_state if @skip_persistence
49
44
  return {} unless has_state?
50
-
51
45
  with_lock do
52
46
  content = File.read(@state_file)
53
47
  JSON.parse(content, symbolize_names: true)
@@ -59,20 +53,16 @@ module Aidp
59
53
 
60
54
  # Save current state
61
55
  def save_state(state_data)
62
- # In test mode, skip file operations to avoid file locking issues
63
- if ENV["RACK_ENV"] == "test" || defined?(RSpec)
56
+ if @skip_persistence
57
+ @memory_state = state_data
64
58
  return
65
59
  end
66
-
67
60
  with_lock do
68
- # Add metadata
69
61
  state_with_metadata = state_data.merge(
70
62
  mode: @mode,
71
63
  project_dir: @project_dir,
72
64
  saved_at: Time.now.iso8601
73
65
  )
74
-
75
- # Write to temporary file first, then rename (atomic operation)
76
66
  temp_file = "#{@state_file}.tmp"
77
67
  File.write(temp_file, JSON.pretty_generate(state_with_metadata))
78
68
  File.rename(temp_file, @state_file)
@@ -81,9 +71,10 @@ module Aidp
81
71
 
82
72
  # Clear state (for fresh start)
83
73
  def clear_state
84
- # In test mode, skip file operations to avoid hanging
85
- return if ENV["RACK_ENV"] == "test" || defined?(RSpec)
86
-
74
+ if @skip_persistence
75
+ @memory_state = {}
76
+ return
77
+ end
87
78
  with_lock do
88
79
  File.delete(@state_file) if File.exist?(@state_file)
89
80
  end
@@ -91,11 +82,7 @@ module Aidp
91
82
 
92
83
  # Get state metadata
93
84
  def state_metadata
94
- # In test mode, return empty metadata to avoid file operations
95
- return {} if ENV["RACK_ENV"] == "test" || defined?(RSpec)
96
-
97
85
  return {} unless has_state?
98
-
99
86
  state = load_state
100
87
  {
101
88
  mode: state[:mode],
@@ -216,21 +203,6 @@ module Aidp
216
203
 
217
204
  # Export state for debugging
218
205
  def export_state
219
- # In test mode, include test variables
220
- if ENV["RACK_ENV"] == "test" || defined?(RSpec)
221
- test_state = {
222
- current_workstream: @test_workstream,
223
- workstream_path: @test_workstream_path,
224
- workstream_branch: @test_workstream_branch
225
- }
226
- return {
227
- state_file: @state_file,
228
- has_state: false,
229
- metadata: {},
230
- state: test_state
231
- }
232
- end
233
-
234
206
  {
235
207
  state_file: @state_file,
236
208
  has_state: has_state?,
@@ -309,11 +281,7 @@ module Aidp
309
281
  @progress_tracker.reset
310
282
  clear_state
311
283
  # Also clear test workstream variables
312
- if ENV["RACK_ENV"] == "test" || defined?(RSpec)
313
- @test_workstream = nil
314
- @test_workstream_path = nil
315
- @test_workstream_branch = nil
316
- end
284
+ # Test-only instance vars removed; rely on persistence skip flag for isolation
317
285
  end
318
286
 
319
287
  # Get progress summary
@@ -472,11 +440,6 @@ module Aidp
472
440
 
473
441
  # Get current workstream slug
474
442
  def current_workstream
475
- # In test mode, use instance variable
476
- if ENV["RACK_ENV"] == "test" || defined?(RSpec)
477
- return @test_workstream
478
- end
479
-
480
443
  state = load_state
481
444
  state[:current_workstream]
482
445
  end
@@ -498,14 +461,6 @@ module Aidp
498
461
  ws = Aidp::Worktree.info(slug: slug, project_dir: @project_dir)
499
462
  return false unless ws
500
463
 
501
- # In test mode, use instance variables
502
- if ENV["RACK_ENV"] == "test" || defined?(RSpec)
503
- @test_workstream = slug
504
- @test_workstream_path = ws[:path]
505
- @test_workstream_branch = ws[:branch]
506
- return true
507
- end
508
-
509
464
  update_state(
510
465
  current_workstream: slug,
511
466
  workstream_path: ws[:path],
@@ -516,14 +471,6 @@ module Aidp
516
471
 
517
472
  # Clear current workstream (switch back to main project)
518
473
  def clear_workstream
519
- # In test mode, use instance variables
520
- if ENV["RACK_ENV"] == "test" || defined?(RSpec)
521
- @test_workstream = nil
522
- @test_workstream_path = nil
523
- @test_workstream_branch = nil
524
- return
525
- end
526
-
527
474
  update_state(
528
475
  current_workstream: nil,
529
476
  workstream_path: nil,
@@ -533,15 +480,6 @@ module Aidp
533
480
 
534
481
  # Get workstream metadata
535
482
  def workstream_metadata
536
- # In test mode, use instance variables
537
- if ENV["RACK_ENV"] == "test" || defined?(RSpec)
538
- return {
539
- slug: @test_workstream,
540
- path: @test_workstream_path,
541
- branch: @test_workstream_branch
542
- }
543
- end
544
-
545
483
  state = load_state
546
484
  {
547
485
  slug: state[:current_workstream],
@@ -642,34 +580,31 @@ module Aidp
642
580
  end
643
581
 
644
582
  def with_lock(&_block)
645
- # In test mode, skip file locking to avoid concurrency issues
646
- if ENV["RACK_ENV"] == "test" || defined?(RSpec)
583
+ # Skip locking entirely when persistence disabled
584
+ if @skip_persistence
647
585
  yield
648
586
  return
649
587
  end
650
588
 
651
- # Improved file-based locking with Async for better concurrency
589
+ # Improved file-based locking using exponential backoff
590
+ require_relative "../concurrency"
652
591
  lock_acquired = false
653
- timeout = 30 # 30 seconds in production
654
-
655
- start_time = Time.now
656
- while (Time.now - start_time) < timeout
657
- begin
658
- # Try to acquire lock
659
- File.open(@lock_file, File::CREAT | File::EXCL | File::WRONLY) do |_lock|
660
- lock_acquired = true
661
- yield
662
- break
663
- end
664
- rescue Errno::EEXIST
665
- # Lock file exists, wait briefly and retry
666
- sleep(0.1)
667
- end
668
- end
669
592
 
670
- unless lock_acquired
671
- raise "Could not acquire state lock within #{timeout} seconds"
593
+ Aidp::Concurrency::Backoff.retry(
594
+ max_attempts: 300, # 300 attempts * ~0.1s = ~30s max
595
+ base: 0.1,
596
+ max_delay: 1.0,
597
+ strategy: :exponential,
598
+ on: [Errno::EEXIST]
599
+ ) do
600
+ # Try to acquire lock
601
+ File.open(@lock_file, File::CREAT | File::EXCL | File::WRONLY) do |_lock|
602
+ lock_acquired = true
603
+ yield
604
+ end
672
605
  end
606
+ rescue Aidp::Concurrency::MaxAttemptsExceededError
607
+ raise "Could not acquire state lock within timeout"
673
608
  ensure
674
609
  # Clean up lock file
675
610
  File.delete(@lock_file) if lock_acquired && File.exist?(@lock_file)
@@ -677,6 +612,7 @@ module Aidp
677
612
 
678
613
  # Clean up stale lock files (older than 30 seconds)
679
614
  def cleanup_stale_lock
615
+ return if @skip_persistence
680
616
  return unless File.exist?(@lock_file)
681
617
 
682
618
  begin
@@ -46,7 +46,7 @@ module Aidp
46
46
  end
47
47
 
48
48
  # Start real-time status updates
49
- def start_status_updates(display_mode = :compact)
49
+ def start_status_updates(display_mode = :compact, async_updates: true)
50
50
  return if @running
51
51
 
52
52
  @running = true
@@ -54,20 +54,30 @@ module Aidp
54
54
  @display_mode = display_mode
55
55
  @last_update = Time.now
56
56
 
57
- unless ENV["RACK_ENV"] == "test" || defined?(RSpec)
58
- require "concurrent"
59
- @status_future = Concurrent::Future.execute do
60
- while @running
61
- begin
62
- collect_status_data
63
- display_status
64
- check_alerts
65
- sleep(@update_interval)
66
- rescue => e
67
- handle_display_error(e)
57
+ if async_updates
58
+ begin
59
+ require "concurrent"
60
+ @status_future = Concurrent::Future.execute do
61
+ while @running
62
+ begin
63
+ collect_status_data
64
+ display_status
65
+ check_alerts
66
+ sleep(@update_interval)
67
+ rescue => e
68
+ handle_display_error(e)
69
+ end
68
70
  end
69
71
  end
72
+ rescue LoadError
73
+ # Fallback: perform single synchronous update if concurrent not available
74
+ collect_status_data
75
+ display_status
70
76
  end
77
+ else
78
+ # Synchronous single update mode (useful for tests)
79
+ collect_status_data
80
+ display_status
71
81
  end
72
82
  end
73
83
 
@@ -20,16 +20,15 @@ module Aidp
20
20
 
21
21
  class DisplayError < TUIError; end
22
22
 
23
- def initialize(prompt: TTY::Prompt.new)
23
+ def initialize(prompt: TTY::Prompt.new, tty: $stdin)
24
24
  @cursor = TTY::Cursor
25
25
  @screen = TTY::Screen
26
26
  @pastel = Pastel.new
27
27
  @prompt = prompt
28
28
 
29
29
  # Headless (non-interactive) detection for test/CI environments:
30
- # - RSpec defined or RSPEC_RUNNING env set
31
- # - STDIN not a TTY (captured by PTY/tmux harness)
32
- @headless = !!(defined?(RSpec) || ENV["RSPEC_RUNNING"] || $stdin.nil? || !$stdin.tty?)
30
+ # - STDIN not a TTY (captured by PTY/tmux harness or test environment)
31
+ @headless = !!(tty.nil? || !tty.tty?)
33
32
 
34
33
  @current_mode = nil
35
34
  @workflow_active = false
@@ -2052,17 +2052,22 @@ module Aidp
2052
2052
  # ============================================================================
2053
2053
 
2054
2054
  # Start the control interface
2055
- def start_control_interface
2055
+ def start_control_interface(async_control: true)
2056
2056
  return unless @control_interface_enabled
2057
2057
 
2058
2058
  @control_mutex.synchronize do
2059
2059
  return if @control_future&.pending?
2060
2060
 
2061
- # Start control interface using concurrent-ruby (skip in test mode)
2062
- # Using Concurrent::Future for background execution with proper thread pool management
2063
- unless ENV["RACK_ENV"] == "test" || defined?(RSpec)
2064
- require "concurrent"
2065
- @control_future = Concurrent::Future.execute { control_interface_loop }
2061
+ if async_control
2062
+ begin
2063
+ require "concurrent"
2064
+ @control_future = Concurrent::Future.execute { control_interface_loop }
2065
+ rescue LoadError
2066
+ # Fallback: run a single synchronous loop iteration if concurrent not available
2067
+ control_interface_loop_iteration
2068
+ end
2069
+ else
2070
+ control_interface_loop_iteration
2066
2071
  end
2067
2072
  end
2068
2073
 
@@ -17,12 +17,18 @@ module Aidp
17
17
 
18
18
  attr_reader :project_dir, :jobs_dir
19
19
 
20
- def initialize(project_dir = Dir.pwd)
20
+ def initialize(project_dir = Dir.pwd, suppress_display: false)
21
21
  @project_dir = project_dir
22
22
  @jobs_dir = File.join(project_dir, ".aidp", "jobs")
23
+ @suppress_display = suppress_display
23
24
  ensure_jobs_directory
24
25
  end
25
26
 
27
+ def display_message(msg, type: :info)
28
+ return if @suppress_display
29
+ super
30
+ end
31
+
26
32
  # Start a background job
27
33
  # Returns job_id
28
34
  def start(mode, options = {})
data/lib/aidp/logger.rb CHANGED
@@ -114,7 +114,7 @@ module Aidp
114
114
  logger
115
115
  rescue => e
116
116
  # Fall back to STDERR if file logging fails
117
- warn "[AIDP Logger] Failed to create log file at #{path}: #{e.message}. Falling back to STDERR."
117
+ Kernel.warn "[AIDP Logger] Failed to create log file at #{path}: #{e.message}. Falling back to STDERR."
118
118
  logger = ::Logger.new($stderr)
119
119
  logger.level = ::Logger::DEBUG
120
120
  logger.formatter = proc { |severity, datetime, progname, msg| "#{msg}\n" }
@@ -39,9 +39,16 @@ module Aidp
39
39
  end
40
40
 
41
41
  module ClassMethods
42
- # Class-level display helper (stateless)
42
+ # Class-level display helper (uses fresh prompt to respect $stdout changes)
43
43
  def display_message(message, type: :info)
44
- TTY::Prompt.new.say(message, color: COLOR_MAP.fetch(type, :white))
44
+ class_message_display_prompt.say(message, color: COLOR_MAP.fetch(type, :white))
45
+ end
46
+
47
+ private
48
+
49
+ # Don't memoize - create fresh prompt each time to respect $stdout redirection in tests
50
+ def class_message_display_prompt
51
+ TTY::Prompt.new
45
52
  end
46
53
  end
47
54
  end
@@ -44,7 +44,7 @@ module Aidp
44
44
  self.class.available?
45
45
  end
46
46
 
47
- def send(prompt:, session: nil)
47
+ def send_message(prompt:, session: nil)
48
48
  raise "claude CLI not available" unless self.class.available?
49
49
 
50
50
  # Smart timeout calculation
@@ -71,8 +71,8 @@ module Aidp
71
71
  name
72
72
  end
73
73
 
74
- def send(prompt:, session: nil)
75
- raise NotImplementedError, "#{self.class} must implement #send"
74
+ def send_message(prompt:, session: nil)
75
+ raise NotImplementedError, "#{self.class} must implement #send_message"
76
76
  end
77
77
 
78
78
  # Fetch MCP servers configured for this provider
@@ -279,8 +279,8 @@ module Aidp
279
279
  error_message = nil
280
280
 
281
281
  begin
282
- # Call the original send method
283
- result = send(prompt: prompt, session: session)
282
+ # Call the original send_message method
283
+ result = send_message(prompt: prompt, session: session)
284
284
  success = true
285
285
 
286
286
  # Extract token usage and cost if available
@@ -34,7 +34,7 @@ module Aidp
34
34
  end
35
35
  end
36
36
 
37
- def send(prompt:, session: nil)
37
+ def send_message(prompt:, session: nil)
38
38
  raise "codex CLI not available" unless self.class.available?
39
39
 
40
40
  # Smart timeout calculation
@@ -31,7 +31,7 @@ module Aidp
31
31
  fetch_mcp_servers_cli || fetch_mcp_servers_config
32
32
  end
33
33
 
34
- def send(prompt:, session: nil)
34
+ def send_message(prompt:, session: nil)
35
35
  raise "cursor-agent not available" unless self.class.available?
36
36
 
37
37
  # Smart timeout calculation
@@ -20,7 +20,7 @@ module Aidp
20
20
  "Google Gemini"
21
21
  end
22
22
 
23
- def send(prompt:, session: nil)
23
+ def send_message(prompt:, session: nil)
24
24
  raise "gemini CLI not available" unless self.class.available?
25
25
 
26
26
  # Smart timeout calculation
@@ -34,7 +34,7 @@ module Aidp
34
34
  end
35
35
  end
36
36
 
37
- def send(prompt:, session: nil)
37
+ def send_message(prompt:, session: nil)
38
38
  raise "copilot CLI not available" unless self.class.available?
39
39
 
40
40
  # Smart timeout calculation
@@ -16,7 +16,7 @@ module Aidp
16
16
  "macos"
17
17
  end
18
18
 
19
- def send(prompt:, session: nil)
19
+ def send_message(prompt:, session: nil)
20
20
  raise "macOS UI not available on this platform" unless self.class.available?
21
21
 
22
22
  debug_provider("macos", "Starting Cursor interaction", {prompt_length: prompt.length})
@@ -22,7 +22,7 @@ module Aidp
22
22
  "OpenCode"
23
23
  end
24
24
 
25
- def send(prompt:, session: nil)
25
+ def send_message(prompt:, session: nil)
26
26
  raise "opencode not available" unless self.class.available?
27
27
 
28
28
  # Smart timeout calculation
@@ -15,8 +15,8 @@ module Aidp
15
15
  class Prompter
16
16
  attr_reader :prompt
17
17
 
18
- def initialize
19
- @prompt = TTY::Prompt.new
18
+ def initialize(prompt: TTY::Prompt.new)
19
+ @prompt = prompt
20
20
  end
21
21
 
22
22
  # Gather all responses for creating a skill
data/lib/aidp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Aidp
4
- VERSION = "0.17.0"
4
+ VERSION = "0.17.1"
5
5
  end
@@ -67,7 +67,7 @@ module Aidp
67
67
 
68
68
  def generate_with_provider(provider, issue)
69
69
  payload = build_prompt(issue)
70
- response = provider.send(prompt: payload)
70
+ response = provider.send_message(prompt: payload)
71
71
  parsed = parse_structured_response(response)
72
72
 
73
73
  return parsed if parsed
@@ -11,6 +11,16 @@ module Aidp
11
11
  # (works for private repositories) and falls back to public REST endpoints
12
12
  # when the CLI is unavailable.
13
13
  class RepositoryClient
14
+ # Binary availability checker for testing
15
+ class BinaryChecker
16
+ def gh_cli_available?
17
+ _stdout, _stderr, status = Open3.capture3("gh", "--version")
18
+ status.success?
19
+ rescue Errno::ENOENT
20
+ false
21
+ end
22
+ end
23
+
14
24
  attr_reader :owner, :repo
15
25
 
16
26
  def self.parse_issues_url(issues_url)
@@ -24,10 +34,11 @@ module Aidp
24
34
  end
25
35
  end
26
36
 
27
- def initialize(owner:, repo:, gh_available: nil)
37
+ def initialize(owner:, repo:, gh_available: nil, binary_checker: BinaryChecker.new)
28
38
  @owner = owner
29
39
  @repo = repo
30
- @gh_available = gh_available.nil? ? gh_cli_available? : gh_available
40
+ @binary_checker = binary_checker
41
+ @gh_available = gh_available.nil? ? @binary_checker.gh_cli_available? : gh_available
31
42
  end
32
43
 
33
44
  def gh_available?
@@ -56,13 +67,6 @@ module Aidp
56
67
 
57
68
  private
58
69
 
59
- def gh_cli_available?
60
- _stdout, _stderr, status = Open3.capture3("gh", "--version")
61
- status.success?
62
- rescue Errno::ENOENT
63
- false
64
- end
65
-
66
70
  def list_issues_via_gh(labels:, state:)
67
71
  json_fields = %w[number title labels updatedAt state url assignees]
68
72
  cmd = ["gh", "issue", "list", "--repo", full_repo, "--state", state, "--json", json_fields.join(",")]