aidp 0.14.1 → 0.15.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.
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "../message_display"
5
+ require_relative "../rescue_logging"
6
+ require_relative "../util"
7
+
8
+ module Aidp
9
+ module Execute
10
+ module DeterministicUnits
11
+ # Represents a deterministic unit configured in aidp.yml.
12
+ # Definitions are immutable and provide helper accessors used by the scheduler.
13
+ class Definition
14
+ VALID_TYPES = [:command, :wait].freeze
15
+ NEXT_KEY_ALIASES = {
16
+ if_pass: :success,
17
+ if_success: :success,
18
+ if_fail: :failure,
19
+ if_failure: :failure,
20
+ if_error: :failure,
21
+ if_timeout: :timeout,
22
+ if_wait: :waiting,
23
+ if_new_item: :event,
24
+ if_event: :event
25
+ }.freeze
26
+
27
+ attr_reader :name, :type, :command, :output_file, :next_map,
28
+ :min_interval_seconds, :max_backoff_seconds, :backoff_multiplier,
29
+ :enabled, :metadata
30
+
31
+ def initialize(config)
32
+ @name = config.fetch(:name)
33
+ @type = normalize_type(config[:type]) || default_type_for(config)
34
+ validate_type!
35
+
36
+ @command = config[:command]
37
+ @output_file = config[:output_file]
38
+ @next_map = normalize_next_config(config[:next] || {})
39
+ @min_interval_seconds = config.fetch(:min_interval_seconds, 60)
40
+ @max_backoff_seconds = config.fetch(:max_backoff_seconds, 900)
41
+ @backoff_multiplier = config.fetch(:backoff_multiplier, 2.0)
42
+ @enabled = config.fetch(:enabled, true)
43
+ @metadata = config.fetch(:metadata, {}).dup
44
+ end
45
+
46
+ def command?
47
+ type == :command
48
+ end
49
+
50
+ def wait?
51
+ type == :wait
52
+ end
53
+
54
+ def next_for(result_status, default: nil)
55
+ next_map[result_status.to_sym] || default || next_map[:else]
56
+ end
57
+
58
+ private
59
+
60
+ def normalize_type(type)
61
+ return nil if type.nil?
62
+ symbol = type.to_sym
63
+ VALID_TYPES.include?(symbol) ? symbol : nil
64
+ end
65
+
66
+ def default_type_for(config)
67
+ return :command if config[:command]
68
+ :wait
69
+ end
70
+
71
+ def validate_type!
72
+ return if VALID_TYPES.include?(type)
73
+ raise ArgumentError, "Unsupported deterministic unit type: #{type.inspect}"
74
+ end
75
+
76
+ def normalize_next_config(raw)
77
+ return {} unless raw
78
+
79
+ raw.each_with_object({}) do |(key, value), normalized|
80
+ symbol_key = key.to_sym
81
+ normalized[NEXT_KEY_ALIASES.fetch(symbol_key, symbol_key)] = value
82
+ end
83
+ end
84
+ end
85
+
86
+ # Result wrapper returned after executing a deterministic unit.
87
+ class Result
88
+ attr_reader :name, :status, :output_path, :started_at, :finished_at,
89
+ :duration, :data, :error
90
+
91
+ def initialize(name:, status:, output_path:, started_at:, finished_at:, data: {}, error: nil)
92
+ @name = name
93
+ @status = status.to_sym
94
+ @output_path = output_path
95
+ @started_at = started_at
96
+ @finished_at = finished_at
97
+ @duration = finished_at - started_at
98
+ @data = data
99
+ @error = error
100
+ end
101
+
102
+ def success?
103
+ status == :success
104
+ end
105
+
106
+ def failure?
107
+ status == :failure
108
+ end
109
+
110
+ def timeout?
111
+ status == :timeout
112
+ end
113
+ end
114
+
115
+ # Executes deterministic units by running commands or internal behaviours.
116
+ class Runner
117
+ include Aidp::MessageDisplay
118
+ include Aidp::RescueLogging
119
+
120
+ DEFAULT_TIMEOUT = 3600 # One hour ceiling for long-running commands
121
+
122
+ def initialize(project_dir, command_runner: nil, clock: Time)
123
+ @project_dir = project_dir
124
+ @clock = clock
125
+ @command_runner = command_runner || build_default_command_runner
126
+ end
127
+
128
+ def run(definition, context = {})
129
+ raise ArgumentError, "Unit #{definition.name} is not enabled" unless definition.enabled
130
+
131
+ case definition.type
132
+ when :command
133
+ execute_command_unit(definition, context)
134
+ when :wait
135
+ execute_wait_unit(definition, context)
136
+ else
137
+ raise ArgumentError, "Unsupported deterministic unit type: #{definition.type}"
138
+ end
139
+ end
140
+
141
+ private
142
+
143
+ def execute_command_unit(definition, context)
144
+ started_at = @clock.now
145
+ display_message("🛠️ Running deterministic unit: #{definition.name}", type: :info)
146
+
147
+ result = @command_runner.call(definition.command, context)
148
+
149
+ data = {
150
+ exit_status: result[:exit_status],
151
+ stdout: result[:stdout],
152
+ stderr: result[:stderr]
153
+ }
154
+
155
+ status = result[:exit_status].to_i.zero? ? :success : :failure
156
+ output_path = write_output(definition, data)
157
+
158
+ display_message("✅ Deterministic unit #{definition.name} finished with status #{status}", type: :success) if status == :success
159
+ display_message("⚠️ Deterministic unit #{definition.name} finished with status #{status}", type: :warning) if status != :success
160
+
161
+ DeterministicUnits::Result.new(
162
+ name: definition.name,
163
+ status: status,
164
+ output_path: output_path,
165
+ started_at: started_at,
166
+ finished_at: @clock.now,
167
+ data: data
168
+ )
169
+ rescue => e
170
+ finished_at = @clock.now
171
+ log_rescue(e, component: "deterministic_runner", action: "execute_command_unit", fallback: "failure", unit: definition.name)
172
+ display_message("❌ Deterministic unit #{definition.name} failed: #{e.message}", type: :error)
173
+
174
+ output_path = write_output(definition, {error: e.message})
175
+
176
+ DeterministicUnits::Result.new(
177
+ name: definition.name,
178
+ status: :failure,
179
+ output_path: output_path,
180
+ started_at: started_at,
181
+ finished_at: finished_at,
182
+ data: {error: e.message},
183
+ error: e
184
+ )
185
+ end
186
+
187
+ def execute_wait_unit(definition, context)
188
+ started_at = @clock.now
189
+
190
+ wait_seconds = definition.metadata.fetch(:interval_seconds, 60)
191
+ backoff_seconds = definition.metadata.fetch(:backoff_seconds, wait_seconds)
192
+ reason = context[:reason] || "Waiting for GitHub activity"
193
+
194
+ display_message("🕒 Deterministic wait: #{definition.name} (#{reason})", type: :info)
195
+ max_window = definition.max_backoff_seconds || backoff_seconds
196
+ sleep_duration = backoff_seconds.clamp(1, max_window)
197
+
198
+ sleep_handler = context[:sleep_handler] || method(:sleep)
199
+ sleep_handler.call(sleep_duration)
200
+
201
+ event_detected = context[:event_detected] == true
202
+
203
+ payload = {
204
+ message: "Waited #{sleep_duration} seconds",
205
+ reason: reason,
206
+ backoff_seconds: sleep_duration,
207
+ event_detected: event_detected
208
+ }
209
+
210
+ output_path = write_output(definition, payload)
211
+
212
+ DeterministicUnits::Result.new(
213
+ name: definition.name,
214
+ status: event_detected ? :event : :waiting,
215
+ output_path: output_path,
216
+ started_at: started_at,
217
+ finished_at: @clock.now,
218
+ data: payload
219
+ )
220
+ end
221
+
222
+ def write_output(definition, payload)
223
+ return nil unless definition.output_file
224
+
225
+ path = File.join(@project_dir, definition.output_file)
226
+ Aidp::Util.safe_file_write(path, payload.to_yaml)
227
+ path
228
+ end
229
+
230
+ def build_default_command_runner
231
+ lambda do |command, _context|
232
+ require "tty-command"
233
+
234
+ cmd = TTY::Command.new(printer: :quiet)
235
+ result = cmd.run(command, chdir: @project_dir)
236
+
237
+ {
238
+ exit_status: result.exit_status,
239
+ stdout: result.out,
240
+ stderr: result.err
241
+ }
242
+ rescue TTY::Command::ExitError => e
243
+ result = e.result
244
+ {
245
+ exit_status: result.exit_status,
246
+ stdout: result.out,
247
+ stderr: result.err
248
+ }
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end
254
+ end
@@ -4,6 +4,7 @@ require "tty-prompt"
4
4
  require "tty-spinner"
5
5
  require_relative "async_work_loop_runner"
6
6
  require_relative "repl_macros"
7
+ require_relative "../rescue_logging"
7
8
 
8
9
  module Aidp
9
10
  module Execute
@@ -19,6 +20,8 @@ module Aidp
19
20
  # repl = InteractiveRepl.new(project_dir, provider_manager, config)
20
21
  # repl.start_work_loop(step_name, step_spec, context)
21
22
  class InteractiveRepl
23
+ include Aidp::RescueLogging
24
+
22
25
  def initialize(project_dir, provider_manager, config, options = {})
23
26
  @project_dir = project_dir
24
27
  @provider_manager = provider_manager
@@ -85,6 +88,7 @@ module Aidp
85
88
  rescue Interrupt
86
89
  handle_interrupt
87
90
  rescue => e
91
+ log_rescue(e, component: "interactive_repl", action: "repl_command_loop", fallback: "error_display")
88
92
  @prompt.error("REPL error: #{e.message}")
89
93
  end
90
94
  end
@@ -98,6 +102,7 @@ module Aidp
98
102
  command = $stdin.gets&.chomp
99
103
  command&.strip
100
104
  rescue => e
105
+ log_rescue(e, component: "interactive_repl", action: "read_command", fallback: nil)
101
106
  @prompt.error("Input error: #{e.message}")
102
107
  nil
103
108
  end
@@ -165,6 +170,7 @@ module Aidp
165
170
  @prompt.error(result[:message])
166
171
  end
167
172
  rescue => e
173
+ log_rescue(e, component: "interactive_repl", action: "handle_command", fallback: "error_display", command: result[:data])
168
174
  @prompt.error("Command error: #{e.message}")
169
175
  end
170
176
 
@@ -215,6 +221,7 @@ module Aidp
215
221
  message: success ? "Reset #{count} commit(s)" : output
216
222
  }
217
223
  rescue => e
224
+ log_rescue(e, component: "interactive_repl", action: "git_reset", fallback: "error_result", count: count)
218
225
  {success: false, message: e.message}
219
226
  end
220
227
 
@@ -128,7 +128,7 @@ module Aidp
128
128
  # Simple task execution - for one-off commands and simple fixes
129
129
  "99_SIMPLE_TASK" => {
130
130
  "templates" => ["implementation/simple_task.md"],
131
- "description" => "Execute Simple Task (one-off commands, quick fixes, linting)",
131
+ "description" => "Execute Simple Task (one-off commands, quick fixes, linting; emit NEXT_UNIT when more tooling is needed)",
132
132
  "outs" => [],
133
133
  "gate" => false,
134
134
  "simple" => true # Special step for simple, focused tasks
@@ -4,6 +4,9 @@ require_relative "prompt_manager"
4
4
  require_relative "checkpoint"
5
5
  require_relative "checkpoint_display"
6
6
  require_relative "guard_policy"
7
+ require_relative "work_loop_unit_scheduler"
8
+ require_relative "deterministic_unit"
9
+ require_relative "agent_signal_parser"
7
10
  require_relative "../harness/test_runner"
8
11
 
9
12
  module Aidp
@@ -53,6 +56,8 @@ module Aidp
53
56
  @options = options
54
57
  @current_state = :ready
55
58
  @state_history = []
59
+ @deterministic_runner = DeterministicUnits::Runner.new(project_dir)
60
+ @unit_scheduler = nil
56
61
  end
57
62
 
58
63
  # Execute a step using fix-forward work loop pattern
@@ -63,86 +68,218 @@ module Aidp
63
68
  @iteration_count = 0
64
69
  transition_to(:ready)
65
70
 
66
- display_message("🔄 Starting fix-forward work loop for step: #{step_name}", type: :info)
67
- display_message(" State machine: READY → APPLY_PATCH → TEST → {PASS → DONE | FAIL → DIAGNOSE → NEXT_PATCH}", type: :info)
71
+ Aidp.logger.info("work_loop", "Starting hybrid work loop execution", step: step_name, max_iterations: MAX_ITERATIONS)
72
+
73
+ display_message("🔄 Starting hybrid work loop for step: #{step_name}", type: :info)
74
+ display_message(" Flow: Deterministic ↔ Agentic with fix-forward core", type: :info)
68
75
 
69
- # Display guard policy status
70
76
  display_guard_policy_status
71
77
 
72
- # Create initial PROMPT.md
78
+ @unit_scheduler = WorkLoopUnitScheduler.new(units_config)
79
+ base_context = context.dup
80
+
81
+ loop do
82
+ unit = @unit_scheduler.next_unit
83
+ break unless unit
84
+
85
+ if unit.deterministic?
86
+ result = @deterministic_runner.run(unit.definition, reason: "scheduled by work loop")
87
+ @unit_scheduler.record_deterministic_result(unit.definition, result)
88
+ next
89
+ end
90
+
91
+ enriched_context = base_context.merge(
92
+ deterministic_outputs: @unit_scheduler.deterministic_context,
93
+ previous_agent_summary: @unit_scheduler.last_agentic_summary
94
+ )
95
+
96
+ agentic_payload = if unit.name == :decide_whats_next
97
+ run_decider_agentic_unit(enriched_context)
98
+ else
99
+ run_primary_agentic_unit(step_spec, enriched_context)
100
+ end
101
+
102
+ @unit_scheduler.record_agentic_result(
103
+ agentic_payload[:raw_result] || {},
104
+ requested_next: agentic_payload[:requested_next],
105
+ summary: agentic_payload[:summary],
106
+ completed: agentic_payload[:completed]
107
+ )
108
+
109
+ return agentic_payload[:response] if agentic_payload[:terminate]
110
+ end
111
+
112
+ build_max_iterations_result
113
+ end
114
+
115
+ private
116
+
117
+ def run_primary_agentic_unit(step_spec, context)
118
+ Aidp.logger.info("work_loop", "Running primary agentic unit", step: @step_name)
119
+
120
+ display_message(" State machine: READY → APPLY_PATCH → TEST → {PASS → DONE | FAIL → DIAGNOSE → NEXT_PATCH}", type: :info)
121
+
122
+ @iteration_count = 0
123
+ @current_state = :ready
124
+ @state_history.clear
125
+
73
126
  create_initial_prompt(step_spec, context)
74
127
 
75
- # Main fix-forward work loop
76
128
  loop do
77
129
  @iteration_count += 1
78
130
  display_message(" Iteration #{@iteration_count} [State: #{STATES[@current_state]}]", type: :info)
79
131
 
80
- break if @iteration_count > MAX_ITERATIONS
132
+ if @iteration_count > MAX_ITERATIONS
133
+ Aidp.logger.error("work_loop", "Max iterations exceeded", step: @step_name, iterations: @iteration_count)
134
+ display_message("⚠️ Max iterations (#{MAX_ITERATIONS}) reached for #{@step_name}", type: :warning)
135
+ display_state_summary
136
+ archive_and_cleanup
137
+ return build_agentic_payload(
138
+ agent_result: nil,
139
+ response: build_max_iterations_result,
140
+ summary: nil,
141
+ completed: false,
142
+ terminate: true
143
+ )
144
+ end
81
145
 
82
- # State: READY - Starting new iteration
83
146
  transition_to(:ready) unless @current_state == :ready
84
147
 
85
- # State: APPLY_PATCH - Agent applies changes
86
148
  transition_to(:apply_patch)
87
- result = apply_patch
149
+ agent_result = apply_patch
88
150
 
89
- # State: TEST - Run tests and linters
90
151
  transition_to(:test)
91
152
  test_results = @test_runner.run_tests
92
153
  lint_results = @test_runner.run_linters
93
154
 
94
- # Record checkpoint at intervals
95
155
  record_periodic_checkpoint(test_results, lint_results)
96
156
 
97
- # Check if tests passed
98
157
  tests_pass = test_results[:success] && lint_results[:success]
99
158
 
100
159
  if tests_pass
101
- # State: PASS - Tests passed
102
160
  transition_to(:pass)
103
161
 
104
- # Check if agent marked work complete
105
- if agent_marked_complete?(result)
106
- # State: DONE - Work complete
162
+ if agent_marked_complete?(agent_result)
107
163
  transition_to(:done)
108
164
  record_final_checkpoint(test_results, lint_results)
109
- display_message("✅ Step #{step_name} completed after #{@iteration_count} iterations", type: :success)
165
+ display_message("✅ Step #{@step_name} completed after #{@iteration_count} iterations", type: :success)
110
166
  display_state_summary
111
167
  archive_and_cleanup
112
- return build_success_result(result)
168
+
169
+ return build_agentic_payload(
170
+ agent_result: agent_result,
171
+ response: build_success_result(agent_result),
172
+ summary: agent_result[:output],
173
+ completed: true,
174
+ terminate: true
175
+ )
113
176
  else
114
- # Tests pass but work not complete - continue
115
177
  display_message(" Tests passed but work not marked complete", type: :info)
116
178
  transition_to(:next_patch)
117
179
  end
118
180
  else
119
- # State: FAIL - Tests failed
120
181
  transition_to(:fail)
121
182
  display_message(" Tests or linters failed", type: :warning)
122
183
 
123
- # State: DIAGNOSE - Analyze failures
124
184
  transition_to(:diagnose)
125
185
  diagnostic = diagnose_failures(test_results, lint_results)
126
186
 
127
- # State: NEXT_PATCH - Prepare for next iteration
128
187
  transition_to(:next_patch)
129
188
  prepare_next_iteration(test_results, lint_results, diagnostic)
130
189
  end
131
190
  end
191
+ end
132
192
 
133
- # Safety: max iterations reached
134
- display_message("⚠️ Max iterations (#{MAX_ITERATIONS}) reached for #{step_name}", type: :warning)
135
- display_state_summary
136
- archive_and_cleanup
137
- build_max_iterations_result
193
+ def run_decider_agentic_unit(context)
194
+ Aidp.logger.info("work_loop", "Running decide_whats_next agentic unit", step: @step_name)
195
+
196
+ prompt = build_decider_prompt(context)
197
+
198
+ agent_result = @provider_manager.execute_with_provider(
199
+ @provider_manager.current_provider,
200
+ prompt,
201
+ {
202
+ step_name: @step_name,
203
+ iteration: @iteration_count,
204
+ project_dir: @project_dir,
205
+ mode: :decide_whats_next
206
+ }
207
+ )
208
+
209
+ requested = AgentSignalParser.extract_next_unit(agent_result[:output])
210
+
211
+ build_agentic_payload(
212
+ agent_result: agent_result,
213
+ response: agent_result,
214
+ summary: agent_result[:output],
215
+ completed: false,
216
+ terminate: false,
217
+ requested_next: requested
218
+ )
138
219
  end
139
220
 
140
- private
221
+ def units_config
222
+ if @config.respond_to?(:work_loop_units_config)
223
+ @config.work_loop_units_config
224
+ else
225
+ {}
226
+ end
227
+ end
228
+
229
+ def build_agentic_payload(agent_result:, response:, summary:, completed:, terminate:, requested_next: nil)
230
+ {
231
+ raw_result: agent_result,
232
+ response: response,
233
+ summary: summary,
234
+ requested_next: requested_next || AgentSignalParser.extract_next_unit(agent_result&.dig(:output)),
235
+ completed: completed,
236
+ terminate: terminate
237
+ }
238
+ end
239
+
240
+ def build_decider_prompt(context)
241
+ outputs = Array(context[:deterministic_outputs])
242
+ summary = context[:previous_agent_summary]
243
+
244
+ sections = []
245
+ sections << "# Decide Next Work Loop Unit"
246
+ sections << ""
247
+ sections << "You are operating in the Aidp work loop. Determine what should happen next."
248
+ sections << ""
249
+ sections << "## Recent Deterministic Outputs"
250
+
251
+ if outputs.empty?
252
+ sections << "- None recorded yet."
253
+ else
254
+ outputs.each do |entry|
255
+ sections << "- #{entry[:name]} (status: #{entry[:status]}, finished_at: #{entry[:finished_at]})"
256
+ sections << " Output: #{entry[:output_path] || "n/a"}"
257
+ end
258
+ end
259
+
260
+ if summary
261
+ sections << ""
262
+ sections << "## Previous Agent Summary"
263
+ sections << summary
264
+ end
265
+
266
+ sections << ""
267
+ sections << "## Instructions"
268
+ sections << "- Decide whether to run another deterministic unit or resume agentic editing."
269
+ sections << "- Announce your decision with `NEXT_UNIT: <unit_name>`."
270
+ sections << "- Valid values: names defined in configuration, `agentic`, or `wait_for_github`."
271
+ sections << "- Provide a concise rationale below."
272
+ sections << ""
273
+ sections << "## Rationale"
274
+
275
+ sections.join("\n")
276
+ end
141
277
 
142
278
  # Transition to a new state in the fix-forward state machine
143
279
  def transition_to(new_state)
144
280
  raise "Invalid state: #{new_state}" unless STATES.key?(new_state)
145
281
 
282
+ old_state = @current_state
146
283
  @state_history << {
147
284
  from: @current_state,
148
285
  to: new_state,
@@ -150,6 +287,7 @@ module Aidp
150
287
  timestamp: Time.now
151
288
  }
152
289
  @current_state = new_state
290
+ Aidp.logger.debug("work_loop", "State transition", from: old_state, to: new_state, iteration: @iteration_count, step: @step_name)
153
291
  end
154
292
 
155
293
  # Display summary of state transitions
@@ -209,20 +347,24 @@ module Aidp
209
347
  prd_content = load_prd
210
348
  style_guide = load_style_guide
211
349
  user_input = format_user_input(context[:user_input])
350
+ deterministic_outputs = Array(context[:deterministic_outputs])
351
+ previous_summary = context[:previous_agent_summary]
212
352
 
213
353
  initial_prompt = build_initial_prompt_content(
214
354
  template: template_content,
215
355
  prd: prd_content,
216
356
  style_guide: style_guide,
217
357
  user_input: user_input,
218
- step_name: @step_name
358
+ step_name: @step_name,
359
+ deterministic_outputs: deterministic_outputs,
360
+ previous_agent_summary: previous_summary
219
361
  )
220
362
 
221
363
  @prompt_manager.write(initial_prompt)
222
364
  display_message(" Created PROMPT.md (#{initial_prompt.length} chars)", type: :info)
223
365
  end
224
366
 
225
- def build_initial_prompt_content(template:, prd:, style_guide:, user_input:, step_name:)
367
+ def build_initial_prompt_content(template:, prd:, style_guide:, user_input:, step_name:, deterministic_outputs:, previous_agent_summary:)
226
368
  parts = []
227
369
 
228
370
  parts << "# Work Loop: #{step_name}"
@@ -252,6 +394,21 @@ module Aidp
252
394
  parts << ""
253
395
  end
254
396
 
397
+ if previous_agent_summary && !previous_agent_summary.empty?
398
+ parts << "## Previous Agent Summary"
399
+ parts << previous_agent_summary
400
+ parts << ""
401
+ end
402
+
403
+ unless deterministic_outputs.empty?
404
+ parts << "## Recent Deterministic Outputs"
405
+ deterministic_outputs.each do |entry|
406
+ parts << "- #{entry[:name]} (status: #{entry[:status]})"
407
+ parts << " Output: #{entry[:output_path] || "n/a"}"
408
+ end
409
+ parts << ""
410
+ end
411
+
255
412
  if style_guide
256
413
  parts << "## LLM Style Guide"
257
414
  parts << style_guide