aidp 0.13.0 → 0.14.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +7 -0
  3. data/lib/aidp/cli/first_run_wizard.rb +28 -303
  4. data/lib/aidp/cli/issue_importer.rb +359 -0
  5. data/lib/aidp/cli.rb +151 -3
  6. data/lib/aidp/daemon/process_manager.rb +146 -0
  7. data/lib/aidp/daemon/runner.rb +232 -0
  8. data/lib/aidp/execute/async_work_loop_runner.rb +216 -0
  9. data/lib/aidp/execute/future_work_backlog.rb +411 -0
  10. data/lib/aidp/execute/guard_policy.rb +246 -0
  11. data/lib/aidp/execute/instruction_queue.rb +131 -0
  12. data/lib/aidp/execute/interactive_repl.rb +335 -0
  13. data/lib/aidp/execute/repl_macros.rb +651 -0
  14. data/lib/aidp/execute/steps.rb +8 -0
  15. data/lib/aidp/execute/work_loop_runner.rb +322 -36
  16. data/lib/aidp/execute/work_loop_state.rb +162 -0
  17. data/lib/aidp/harness/config_schema.rb +88 -0
  18. data/lib/aidp/harness/configuration.rb +48 -1
  19. data/lib/aidp/harness/ui/enhanced_workflow_selector.rb +2 -0
  20. data/lib/aidp/init/doc_generator.rb +256 -0
  21. data/lib/aidp/init/project_analyzer.rb +343 -0
  22. data/lib/aidp/init/runner.rb +83 -0
  23. data/lib/aidp/init.rb +5 -0
  24. data/lib/aidp/logger.rb +279 -0
  25. data/lib/aidp/setup/wizard.rb +777 -0
  26. data/lib/aidp/tooling_detector.rb +115 -0
  27. data/lib/aidp/version.rb +1 -1
  28. data/lib/aidp/watch/build_processor.rb +282 -0
  29. data/lib/aidp/watch/plan_generator.rb +166 -0
  30. data/lib/aidp/watch/plan_processor.rb +83 -0
  31. data/lib/aidp/watch/repository_client.rb +243 -0
  32. data/lib/aidp/watch/runner.rb +93 -0
  33. data/lib/aidp/watch/state_store.rb +105 -0
  34. data/lib/aidp/watch.rb +9 -0
  35. data/lib/aidp.rb +14 -0
  36. data/templates/implementation/simple_task.md +36 -0
  37. metadata +26 -1
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Aidp
6
+ module Daemon
7
+ # Manages daemon process lifecycle: start, stop, status, attach
8
+ # Handles PID file management and process communication
9
+ class ProcessManager
10
+ DAEMON_DIR = ".aidp/daemon"
11
+ PID_FILE = "#{DAEMON_DIR}/aidp.pid"
12
+ SOCKET_FILE = "#{DAEMON_DIR}/aidp.sock"
13
+ LOG_FILE = "#{DAEMON_DIR}/daemon.log"
14
+
15
+ def initialize(project_dir = Dir.pwd)
16
+ @project_dir = project_dir
17
+ @pid_file_path = File.join(@project_dir, PID_FILE)
18
+ @socket_path = File.join(@project_dir, SOCKET_FILE)
19
+ @log_path = File.join(@project_dir, LOG_FILE)
20
+ ensure_daemon_dir
21
+ end
22
+
23
+ # Check if daemon is running
24
+ def running?
25
+ return false unless File.exist?(@pid_file_path)
26
+
27
+ pid = read_pid
28
+ return false unless pid
29
+
30
+ Process.kill(0, pid)
31
+ true
32
+ rescue Errno::ESRCH
33
+ # Process doesn't exist
34
+ cleanup_stale_files
35
+ false
36
+ rescue Errno::EPERM
37
+ # Process exists but we don't have permission
38
+ true
39
+ end
40
+
41
+ # Get daemon PID
42
+ def pid
43
+ return nil unless running?
44
+ read_pid
45
+ end
46
+
47
+ # Get daemon status summary
48
+ def status
49
+ if running?
50
+ {
51
+ running: true,
52
+ pid: pid,
53
+ socket: File.exist?(@socket_path),
54
+ log_file: @log_path
55
+ }
56
+ else
57
+ {
58
+ running: false,
59
+ pid: nil,
60
+ socket: false,
61
+ log_file: @log_path
62
+ }
63
+ end
64
+ end
65
+
66
+ # Write PID file for daemon
67
+ def write_pid(daemon_pid = Process.pid)
68
+ File.write(@pid_file_path, daemon_pid.to_s)
69
+ end
70
+
71
+ # Remove PID file
72
+ def remove_pid
73
+ File.delete(@pid_file_path) if File.exist?(@pid_file_path)
74
+ end
75
+
76
+ # Stop daemon gracefully
77
+ def stop(timeout: 30)
78
+ return {success: false, message: "Daemon not running"} unless running?
79
+
80
+ daemon_pid = pid
81
+ Process.kill("TERM", daemon_pid)
82
+
83
+ # Wait for process to exit
84
+ timeout.times do
85
+ sleep 1
86
+ unless process_exists?(daemon_pid)
87
+ cleanup_stale_files
88
+ return {success: true, message: "Daemon stopped gracefully"}
89
+ end
90
+ end
91
+
92
+ # Force kill if still running
93
+ Process.kill("KILL", daemon_pid)
94
+ cleanup_stale_files
95
+ {success: true, message: "Daemon force-killed after timeout"}
96
+ rescue Errno::ESRCH
97
+ cleanup_stale_files
98
+ {success: true, message: "Daemon already stopped"}
99
+ rescue => e
100
+ {success: false, message: "Error stopping daemon: #{e.message}"}
101
+ end
102
+
103
+ # Get socket path for IPC
104
+ attr_reader :socket_path
105
+
106
+ # Check if socket exists
107
+ def socket_exists?
108
+ File.exist?(@socket_path)
109
+ end
110
+
111
+ # Remove socket file
112
+ def remove_socket
113
+ File.delete(@socket_path) if File.exist?(@socket_path)
114
+ end
115
+
116
+ # Get log file path
117
+ def log_file_path
118
+ @log_path
119
+ end
120
+
121
+ private
122
+
123
+ def ensure_daemon_dir
124
+ daemon_dir = File.join(@project_dir, DAEMON_DIR)
125
+ FileUtils.mkdir_p(daemon_dir) unless Dir.exist?(daemon_dir)
126
+ end
127
+
128
+ def read_pid
129
+ return nil unless File.exist?(@pid_file_path)
130
+ File.read(@pid_file_path).strip.to_i
131
+ end
132
+
133
+ def process_exists?(daemon_pid)
134
+ Process.kill(0, daemon_pid)
135
+ true
136
+ rescue Errno::ESRCH
137
+ false
138
+ end
139
+
140
+ def cleanup_stale_files
141
+ remove_pid
142
+ remove_socket
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require_relative "process_manager"
5
+ require_relative "../execute/async_work_loop_runner"
6
+
7
+ module Aidp
8
+ module Daemon
9
+ # Main daemon runner for background mode execution
10
+ # Manages work loops, watch mode, and IPC communication
11
+ class Runner
12
+ def initialize(project_dir, config, options = {})
13
+ @project_dir = project_dir
14
+ @config = config
15
+ @options = options
16
+ @process_manager = ProcessManager.new(project_dir)
17
+ @running = false
18
+ @work_loop_runner = nil
19
+ @watch_runner = nil
20
+ @ipc_server = nil
21
+ end
22
+
23
+ # Start daemon in background
24
+ def start_daemon(mode: :watch)
25
+ if @process_manager.running?
26
+ return {success: false, message: "Daemon already running (PID: #{@process_manager.pid})"}
27
+ end
28
+
29
+ # Fork daemon process
30
+ daemon_pid = fork do
31
+ Process.daemon(true)
32
+ @process_manager.write_pid
33
+ run_daemon(mode)
34
+ end
35
+
36
+ Process.detach(daemon_pid)
37
+
38
+ # Wait for daemon to start
39
+ sleep 0.5
40
+
41
+ if @process_manager.running?
42
+ {
43
+ success: true,
44
+ message: "Daemon started in #{mode} mode",
45
+ pid: daemon_pid,
46
+ log_file: @process_manager.log_file_path
47
+ }
48
+ else
49
+ {success: false, message: "Failed to start daemon"}
50
+ end
51
+ end
52
+
53
+ # Attach to running daemon (restore REPL)
54
+ def attach
55
+ unless @process_manager.running?
56
+ return {success: false, message: "No daemon running"}
57
+ end
58
+
59
+ unless @process_manager.socket_exists?
60
+ return {success: false, message: "Daemon socket not available"}
61
+ end
62
+
63
+ {
64
+ success: true,
65
+ message: "Attached to daemon",
66
+ pid: @process_manager.pid,
67
+ activity: Aidp.logger.info("activity", "summary")
68
+ }
69
+ end
70
+
71
+ # Run daemon main loop (called in forked process)
72
+ def run_daemon(mode)
73
+ Aidp.logger.info("daemon_lifecycle", "Daemon started", mode: mode, pid: Process.pid)
74
+ @running = true
75
+
76
+ # Set up signal handlers
77
+ setup_signal_handlers
78
+
79
+ # Start IPC server
80
+ start_ipc_server
81
+
82
+ # Run appropriate mode
83
+ case mode
84
+ when :watch
85
+ run_watch_mode
86
+ when :work_loop
87
+ run_work_loop_mode
88
+ else
89
+ Aidp.logger.error("daemon_error", "Unknown mode: #{mode}")
90
+ end
91
+ rescue => e
92
+ Aidp.logger.error("daemon_error", "Fatal error: #{e.message}", backtrace: e.backtrace.first(5).join("\n"))
93
+ ensure
94
+ cleanup
95
+ end
96
+
97
+ private
98
+
99
+ def setup_signal_handlers
100
+ Signal.trap("TERM") do
101
+ Aidp.logger.info("daemon_lifecycle", "SIGTERM received, shutting down gracefully")
102
+ @running = false
103
+ end
104
+
105
+ Signal.trap("INT") do
106
+ Aidp.logger.info("daemon_lifecycle", "SIGINT received, shutting down gracefully")
107
+ @running = false
108
+ end
109
+ end
110
+
111
+ def start_ipc_server
112
+ # Create Unix socket for IPC
113
+ @ipc_server = UNIXServer.new(@process_manager.socket_path)
114
+
115
+ Thread.new do
116
+ while @running
117
+ begin
118
+ client = @ipc_server.accept_nonblock
119
+ handle_ipc_client(client)
120
+ rescue IO::WaitReadable
121
+ IO.select([@ipc_server], nil, nil, 1)
122
+ rescue => e
123
+ Aidp.logger.error("ipc_error", "IPC server error: #{e.message}")
124
+ end
125
+ end
126
+ end
127
+ rescue => e
128
+ Aidp.logger.error("ipc_error", "Failed to start IPC server: #{e.message}")
129
+ end
130
+
131
+ def handle_ipc_client(client)
132
+ command = client.gets&.strip
133
+ return unless command
134
+
135
+ response = case command
136
+ when "status"
137
+ status_response
138
+ when "stop"
139
+ stop_response
140
+ when "attach"
141
+ attach_response
142
+ else
143
+ {error: "Unknown command: #{command}"}
144
+ end
145
+
146
+ client.puts(response.to_json)
147
+ client.close
148
+ rescue => e
149
+ Aidp.logger.error("ipc_error", "Error handling client: #{e.message}")
150
+ begin
151
+ client.close
152
+ rescue
153
+ nil
154
+ end
155
+ end
156
+
157
+ def status_response
158
+ {
159
+ status: "running",
160
+ pid: Process.pid,
161
+ mode: @watch_runner ? "watch" : "work_loop",
162
+ uptime: Time.now.to_i
163
+ }
164
+ end
165
+
166
+ def stop_response
167
+ @running = false
168
+ {status: "stopping"}
169
+ end
170
+
171
+ def attach_response
172
+ {
173
+ status: "attached",
174
+ activity: Aidp.logger.info("activity", "summary")
175
+ }
176
+ end
177
+
178
+ def run_watch_mode
179
+ Aidp.logger.info("watch_mode", "Starting watch mode")
180
+
181
+ # Initialize watch runner
182
+ require_relative "../watch/runner"
183
+ @watch_runner = Aidp::Watch::Runner.new(@project_dir, @config, @options)
184
+
185
+ while @running
186
+ begin
187
+ @watch_runner.run_cycle
188
+ Aidp.logger.info("watch_mode", "Watch cycle completed")
189
+ sleep(@options[:interval] || 60)
190
+ rescue => e
191
+ Aidp.logger.error("watch_error", "Watch cycle error: #{e.message}")
192
+ sleep 30 # Back off on error
193
+ end
194
+ end
195
+
196
+ Aidp.logger.info("watch_mode", "Watch mode stopped")
197
+ end
198
+
199
+ def run_work_loop_mode
200
+ Aidp.logger.info("daemon_lifecycle", "Starting work loop mode")
201
+
202
+ # This would integrate with AsyncWorkLoopRunner
203
+ # For now, just log that we're running
204
+ while @running
205
+ Aidp.logger.debug("heartbeat", "Daemon running")
206
+ sleep 10
207
+ end
208
+
209
+ Aidp.logger.info("daemon_lifecycle", "Work loop mode stopped")
210
+ end
211
+
212
+ def cleanup
213
+ Aidp.logger.info("daemon_lifecycle", "Daemon cleanup started")
214
+
215
+ # Stop work loop if running
216
+ @work_loop_runner&.cancel(save_checkpoint: true)
217
+
218
+ # Stop watch runner if running
219
+ @watch_runner = nil
220
+
221
+ # Close IPC server
222
+ @ipc_server&.close
223
+ @process_manager.remove_socket
224
+
225
+ # Remove PID file
226
+ @process_manager.remove_pid
227
+
228
+ Aidp.logger.info("daemon_lifecycle", "Daemon stopped cleanly")
229
+ end
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "work_loop_runner"
4
+ require_relative "work_loop_state"
5
+ require_relative "instruction_queue"
6
+
7
+ module Aidp
8
+ module Execute
9
+ # Asynchronous wrapper around WorkLoopRunner
10
+ # Runs work loop in a separate thread while maintaining REPL responsiveness
11
+ #
12
+ # Responsibilities:
13
+ # - Execute work loop in background thread
14
+ # - Monitor execution state (pause, resume, cancel)
15
+ # - Merge queued instructions at iteration boundaries
16
+ # - Stream output to main thread for display
17
+ # - Handle graceful cancellation with checkpoint save
18
+ class AsyncWorkLoopRunner
19
+ attr_reader :state, :instruction_queue, :work_thread
20
+
21
+ def initialize(project_dir, provider_manager, config, options = {})
22
+ @project_dir = project_dir
23
+ @provider_manager = provider_manager
24
+ @config = config
25
+ @options = options
26
+ @state = WorkLoopState.new
27
+ @instruction_queue = InstructionQueue.new
28
+ @work_thread = nil
29
+ @sync_runner = nil
30
+ end
31
+
32
+ # Start async work loop execution
33
+ # Returns immediately, work continues in background thread
34
+ def execute_step_async(step_name, step_spec, context = {})
35
+ raise WorkLoopState::StateError, "Work loop already running" unless @state.idle?
36
+
37
+ @state.start!
38
+ @step_name = step_name
39
+ @step_spec = step_spec
40
+ @context = context
41
+
42
+ @work_thread = Thread.new do
43
+ run_async_loop
44
+ rescue => e
45
+ @state.error!(e)
46
+ @state.append_output("Work loop error: #{e.message}", type: :error)
47
+ ensure
48
+ @work_thread = nil
49
+ end
50
+
51
+ # Allow thread to start
52
+ Thread.pass
53
+
54
+ {status: "started", state: @state.summary}
55
+ end
56
+
57
+ # Wait for work loop to complete (blocking)
58
+ def wait
59
+ return unless @work_thread
60
+ @work_thread.join
61
+ build_final_result
62
+ end
63
+
64
+ # Check if work loop is running
65
+ def running?
66
+ @work_thread&.alive? && @state.running?
67
+ end
68
+
69
+ # Pause execution
70
+ def pause
71
+ return unless running?
72
+ @state.pause!
73
+ {status: "paused", iteration: @state.iteration}
74
+ end
75
+
76
+ # Resume execution
77
+ def resume
78
+ return unless @state.paused?
79
+ @state.resume!
80
+ {status: "resumed", iteration: @state.iteration}
81
+ end
82
+
83
+ # Cancel execution gracefully
84
+ def cancel(save_checkpoint: true)
85
+ return if @state.cancelled? || @state.completed?
86
+
87
+ @state.cancel!
88
+ @state.append_output("Cancellation requested, waiting for safe stopping point...", type: :warning)
89
+
90
+ # Wait for thread to notice cancellation
91
+ @work_thread&.join(5) # 5 second timeout
92
+
93
+ if save_checkpoint && @sync_runner
94
+ @state.append_output("Saving checkpoint before exit...", type: :info)
95
+ save_cancellation_checkpoint
96
+ end
97
+
98
+ {status: "cancelled", iteration: @state.iteration}
99
+ end
100
+
101
+ # Add instruction to queue (will be merged at next iteration)
102
+ def enqueue_instruction(content, type: :user_input, priority: :normal)
103
+ @instruction_queue.enqueue(content, type: type, priority: priority)
104
+ @state.enqueue_instruction(content)
105
+
106
+ {
107
+ status: "enqueued",
108
+ queued_count: @instruction_queue.count,
109
+ message: "Instruction will be merged in next iteration"
110
+ }
111
+ end
112
+
113
+ # Get streaming output
114
+ def drain_output
115
+ @state.drain_output
116
+ end
117
+
118
+ # Get current status
119
+ def status
120
+ summary = @state.summary
121
+ summary[:queued_instructions] = @instruction_queue.summary
122
+ summary[:thread_alive] = @work_thread&.alive? || false
123
+ summary
124
+ end
125
+
126
+ private
127
+
128
+ # Main async execution loop
129
+ def run_async_loop
130
+ # Create synchronous runner (runs in this thread)
131
+ @sync_runner = WorkLoopRunner.new(
132
+ @project_dir,
133
+ @provider_manager,
134
+ @config,
135
+ @options.merge(async_mode: true)
136
+ )
137
+
138
+ @state.append_output("🚀 Starting async work loop: #{@step_name}", type: :info)
139
+
140
+ # Hook into sync runner to check for pause/cancel
141
+ result = execute_with_monitoring
142
+
143
+ if @state.cancelled?
144
+ @state.append_output("Work loop cancelled at iteration #{@state.iteration}", type: :warning)
145
+ else
146
+ @state.complete!
147
+ @state.append_output("✅ Work loop completed: #{@step_name}", type: :success)
148
+ end
149
+
150
+ result
151
+ rescue => e
152
+ @state.error!(e)
153
+ @state.append_output("Error in work loop: #{e.message}\n#{e.backtrace.first(3).join("\n")}", type: :error)
154
+ raise
155
+ end
156
+
157
+ # Execute sync runner with monitoring for control signals
158
+ def execute_with_monitoring
159
+ # We need to modify WorkLoopRunner to support iteration callbacks
160
+ # For now, we'll wrap the execute_step call
161
+ #
162
+ # TODO: This requires enhancing WorkLoopRunner to accept iteration callbacks
163
+ # See: https://github.com/viamin/aidp/issues/103
164
+ @sync_runner.execute_step(@step_name, @step_spec, @context)
165
+ end
166
+
167
+ # Save checkpoint when cancelling
168
+ def save_cancellation_checkpoint
169
+ return unless @sync_runner
170
+
171
+ checkpoint = @sync_runner.instance_variable_get(:@checkpoint)
172
+ return unless checkpoint
173
+
174
+ checkpoint.record_checkpoint(
175
+ @step_name,
176
+ @state.iteration,
177
+ {
178
+ cancelled: true,
179
+ reason: "User cancelled via REPL",
180
+ queued_instructions: @instruction_queue.count
181
+ }
182
+ )
183
+ end
184
+
185
+ # Build final result from state
186
+ def build_final_result
187
+ if @state.completed?
188
+ {
189
+ status: "completed",
190
+ iterations: @state.iteration,
191
+ message: "Work loop completed successfully"
192
+ }
193
+ elsif @state.cancelled?
194
+ {
195
+ status: "cancelled",
196
+ iterations: @state.iteration,
197
+ message: "Work loop cancelled by user"
198
+ }
199
+ elsif @state.error?
200
+ {
201
+ status: "error",
202
+ iterations: @state.iteration,
203
+ error: @state.last_error&.message,
204
+ message: "Work loop encountered an error"
205
+ }
206
+ else
207
+ {
208
+ status: "unknown",
209
+ iterations: @state.iteration,
210
+ message: "Work loop ended in unknown state"
211
+ }
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end