ocak 0.3.0 → 0.4.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.
data/lib/ocak/logger.rb CHANGED
@@ -166,8 +166,11 @@ module Ocak
166
166
  def format_tool_result(prefix, event)
167
167
  return nil unless event[:is_test_result]
168
168
 
169
- color = event[:passed] ? :green : :red
170
- status = event[:passed] ? 'PASS' : 'FAIL'
169
+ color, status = case event[:passed]
170
+ when true then [:green, 'PASS']
171
+ when false then [:red, 'FAIL']
172
+ else [:yellow, 'UNKNOWN']
173
+ end
171
174
  "#{prefix} #{c(color)}#{c(:bold)}[TEST #{status}]#{c(:reset)} #{c(:dim)}#{event[:command]}#{c(:reset)}"
172
175
  end
173
176
 
@@ -123,7 +123,8 @@ module Ocak
123
123
  return true if status.success?
124
124
 
125
125
  @logger.warn("Rebase conflict, aborting rebase: #{stderr}")
126
- git('rebase', '--abort', chdir: worktree.path)
126
+ _, abort_stderr, abort_status = git('rebase', '--abort', chdir: worktree.path)
127
+ @logger.warn("git rebase --abort failed: #{abort_stderr}") unless abort_status.success?
127
128
 
128
129
  # Fall back to merge strategy
129
130
  @logger.info('Attempting merge strategy instead...')
@@ -149,7 +150,7 @@ module Ocak
149
150
 
150
151
  result = @claude.run_agent(
151
152
  'implementer',
152
- "Resolve these merge conflicts. Conflicting files:\n#{conflicting.join("\n")}\n\n" \
153
+ "Resolve these merge conflicts.\n\n<conflicting_files>\n#{conflicting.join("\n")}\n</conflicting_files>\n\n" \
153
154
  'Open each file, find conflict markers (<<<<<<< ======= >>>>>>>), and resolve them. ' \
154
155
  'Then run `git add` on each resolved file.',
155
156
  chdir: worktree.path
@@ -159,7 +160,11 @@ module Ocak
159
160
  # Check if all conflicts resolved
160
161
  remaining, = git('diff', '--name-only', '--diff-filter=U', chdir: worktree.path)
161
162
  if remaining.strip.empty?
162
- git('commit', '--no-edit', chdir: worktree.path)
163
+ _, commit_stderr, commit_status = git('commit', '--no-edit', chdir: worktree.path)
164
+ unless commit_status.success?
165
+ @logger.error("Commit after conflict resolution failed: #{commit_stderr}")
166
+ return false
167
+ end
163
168
  @logger.info('Merge conflicts resolved by agent')
164
169
  return true
165
170
  end
@@ -175,13 +180,14 @@ module Ocak
175
180
  return true unless test_cmd
176
181
 
177
182
  @logger.info('Running tests after rebase...')
178
- _, _, status = shell(test_cmd, chdir: worktree.path)
183
+ stdout, stderr, status = shell(test_cmd, chdir: worktree.path)
179
184
 
180
185
  if status.success?
181
186
  @logger.info('Tests passed after rebase')
182
187
  true
183
188
  else
184
189
  @logger.warn('Tests failed after rebase')
190
+ @logger.debug("Test output:\n#{stdout[0..2000]}\n#{stderr[0..500]}")
185
191
  false
186
192
  end
187
193
  end
@@ -24,14 +24,20 @@ module Ocak
24
24
  @shutdown_check = shutdown_check
25
25
  end
26
26
 
27
- def run_pipeline(issue_number, logger:, claude:, chdir: nil, skip_steps: [], complexity: 'full')
27
+ def run_pipeline(issue_number, logger:, claude:, chdir: nil, skip_steps: [], complexity: 'full', # rubocop:disable Metrics/ParameterLists
28
+ steps: nil, verification_model: nil,
29
+ post_start_comment: true, post_summary_comment: true)
30
+ @logger = logger
31
+ @custom_steps = steps
32
+ @verification_model = verification_model
33
+ @post_summary_comment = post_summary_comment
28
34
  chdir ||= @config.project_dir
29
35
  logger.info("=== Starting pipeline for issue ##{issue_number} (#{complexity}) ===")
30
36
 
31
37
  report = RunReport.new(complexity: complexity)
32
38
  state = build_initial_state(complexity, report)
33
39
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
34
- post_pipeline_start_comment(issue_number, state)
40
+ post_pipeline_start_comment(issue_number, state) if post_start_comment
35
41
 
36
42
  failure = run_pipeline_steps(issue_number, state, logger: logger, claude: claude, chdir: chdir,
37
43
  skip_steps: skip_steps)
@@ -52,7 +58,7 @@ module Ocak
52
58
  def build_initial_state(complexity, report)
53
59
  { last_review_output: nil, had_fixes: false, completed_steps: [], total_cost: 0.0,
54
60
  complexity: complexity, steps_run: 0, steps_skipped: 0,
55
- audit_output: nil, audit_blocked: false, report: report }
61
+ audit_output: nil, audit_blocked: false, report: report, step_results: {} }
56
62
  end
57
63
 
58
64
  def handle_interrupted(issue_number, state, report, logger)
@@ -69,50 +75,98 @@ module Ocak
69
75
  def finish_success(issue_number, state, report, start_time, logger)
70
76
  duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time).round
71
77
  save_report(report, issue_number, success: true)
72
- post_pipeline_summary_comment(issue_number, state, duration, success: true)
78
+ post_pipeline_summary_comment(issue_number, state, duration, success: true) if @post_summary_comment
73
79
  logger.info("=== Pipeline complete for issue ##{issue_number} ===")
74
80
  { success: true, output: 'Pipeline completed successfully',
75
- audit_blocked: state[:audit_blocked], audit_output: state[:audit_output] }
81
+ audit_blocked: state[:audit_blocked], audit_output: state[:audit_output],
82
+ step_results: state[:step_results], total_cost: state[:total_cost], steps_run: state[:steps_run] }
76
83
  end
77
84
 
78
85
  def build_interrupted_result(state)
79
- last_step = state[:completed_steps].any? ? @config.steps[state[:completed_steps].last] : nil
86
+ last_step = state[:completed_steps].any? ? active_steps[state[:completed_steps].last] : nil
80
87
  last_role = last_step ? symbolize(last_step)[:role].to_s : 'startup'
81
- { success: false, phase: last_role, output: 'Pipeline interrupted', interrupted: true }
88
+ { success: false, phase: last_role, output: 'Pipeline interrupted', interrupted: true,
89
+ step_results: state[:step_results], total_cost: state[:total_cost], steps_run: state[:steps_run] }
82
90
  end
83
91
 
84
92
  def post_failure_and_return(issue_number, state, failure, start_time)
85
93
  duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time).round
86
- post_pipeline_summary_comment(issue_number, state, duration, success: false,
87
- failed_phase: failure[:phase])
88
- failure
94
+ if @post_summary_comment
95
+ post_pipeline_summary_comment(issue_number, state, duration, success: false,
96
+ failed_phase: failure[:phase])
97
+ end
98
+ failure.merge(step_results: state[:step_results], total_cost: state[:total_cost],
99
+ steps_run: state[:steps_run])
89
100
  end
90
101
 
91
102
  def run_pipeline_steps(issue_number, state, logger:, claude:, chdir:, skip_steps: [])
92
- @config.steps.each_with_index do |step, idx|
103
+ @skip_steps = skip_steps
104
+ steps = active_steps
105
+ idx = 0
106
+ while idx < steps.size
93
107
  break if check_shutdown(state, logger)
94
108
 
95
- step = symbolize(step)
96
- role = step[:role].to_s
97
- agent = step[:agent].to_s
109
+ step = symbolize(steps[idx])
110
+ if step[:parallel]
111
+ group = collect_parallel_group(steps, idx)
112
+ failure = run_parallel_group(group, issue_number, state, logger: logger, claude: claude, chdir: chdir)
113
+ idx += group.size
114
+ else
115
+ failure = run_single_step(step, idx, issue_number, state, logger: logger, claude: claude, chdir: chdir)
116
+ idx += 1
117
+ end
118
+ return failure if failure
119
+ end
120
+ nil
121
+ end
122
+
123
+ def collect_parallel_group(steps, start_idx)
124
+ group = []
125
+ idx = start_idx
126
+ while idx < steps.size
127
+ step = symbolize(steps[idx])
128
+ break unless step[:parallel]
98
129
 
99
- next if handle_already_completed(idx, role, skip_steps, logger)
130
+ group << [step, idx]
131
+ idx += 1
132
+ end
133
+ group
134
+ end
100
135
 
101
- reason = skip_reason(step, state)
102
- if reason
103
- logger.info("Skipping #{role} \u2014 #{reason}")
104
- record_skipped_step(issue_number, state, idx, agent, role, reason)
105
- next
136
+ def run_parallel_group(group, issue_number, state, logger:, claude:, chdir:)
137
+ mutex = Mutex.new
138
+ threads = group.map do |step, idx|
139
+ Thread.new do
140
+ run_single_step(step, idx, issue_number, state, logger: logger, claude: claude,
141
+ chdir: chdir, mutex: mutex)
142
+ rescue StandardError => e
143
+ logger.error("#{step[:role]} thread failed: #{e.message}")
144
+ nil
106
145
  end
146
+ end
107
147
 
108
- result = execute_step(step, issue_number, state[:last_review_output], logger: logger, claude: claude,
109
- chdir: chdir)
110
- state[:report].record_step(index: idx, agent: agent, role: role, status: 'completed', result: result)
111
- ctx = StepContext.new(issue_number, idx, role, result, state, logger, chdir)
112
- failure = record_step_result(ctx)
113
- return failure if failure
148
+ results = threads.map(&:value)
149
+ results.compact.find { |r| r.is_a?(Hash) && !r[:success] }
150
+ end
151
+
152
+ def run_single_step(step, idx, issue_number, state, logger:, claude:, chdir:, mutex: nil) # rubocop:disable Metrics/ParameterLists
153
+ role = step[:role].to_s
154
+ agent = step[:agent].to_s
155
+
156
+ return nil if handle_already_completed(idx, role, @skip_steps, logger)
157
+
158
+ reason = skip_reason(step, state)
159
+ if reason
160
+ logger.info("Skipping #{role} \u2014 #{reason}")
161
+ record_skipped_step(issue_number, state, idx, agent, role, reason)
162
+ return nil
114
163
  end
115
- nil
164
+
165
+ result = execute_step(step, issue_number, state[:last_review_output], logger: logger, claude: claude,
166
+ chdir: chdir)
167
+ state[:report].record_step(index: idx, agent: agent, role: role, status: 'completed', result: result)
168
+ ctx = StepContext.new(issue_number, idx, role, result, state, logger, chdir)
169
+ record_step_result(ctx, mutex: mutex)
116
170
  end
117
171
 
118
172
  def check_shutdown(state, logger)
@@ -141,14 +195,13 @@ module Ocak
141
195
  logger.info("--- Phase: #{role} (#{agent}) ---")
142
196
  post_step_comment(issue_number, "\u{1F504} **Phase: #{role}** (#{agent})")
143
197
  prompt = build_step_prompt(role, issue_number, review_output)
144
- claude.run_agent(agent.tr('_', '-'), prompt, chdir: chdir)
198
+ opts = { chdir: chdir }
199
+ opts[:model] = step[:model].to_s if step[:model]
200
+ claude.run_agent(agent.tr('_', '-'), prompt, **opts)
145
201
  end
146
202
 
147
- def record_step_result(ctx)
148
- update_pipeline_state(ctx.role, ctx.result, ctx.state)
149
- ctx.state[:completed_steps] << ctx.idx
150
- ctx.state[:steps_run] += 1
151
- ctx.state[:total_cost] += ctx.result.cost_usd.to_f
203
+ def record_step_result(ctx, mutex: nil)
204
+ sync(mutex) { accumulate_state(ctx) }
152
205
  save_step_progress(ctx)
153
206
  write_step_output(ctx.issue_number, ctx.idx, ctx.role, ctx.result.output)
154
207
  post_step_completion_comment(ctx.issue_number, ctx.role, ctx.result)
@@ -156,6 +209,22 @@ module Ocak
156
209
  check_step_failure(ctx) || check_cost_budget(ctx.state, ctx.logger)
157
210
  end
158
211
 
212
+ def accumulate_state(ctx)
213
+ update_pipeline_state(ctx.role, ctx.result, ctx.state)
214
+ ctx.state[:completed_steps] << ctx.idx
215
+ ctx.state[:steps_run] += 1
216
+ ctx.state[:total_cost] += ctx.result.cost_usd.to_f
217
+ ctx.state[:step_results][ctx.role] = ctx.result
218
+ end
219
+
220
+ def sync(mutex, &)
221
+ if mutex
222
+ mutex.synchronize(&)
223
+ else
224
+ yield
225
+ end
226
+ end
227
+
159
228
  def save_step_progress(ctx)
160
229
  pipeline_state.save(ctx.issue_number,
161
230
  completed_steps: ctx.state[:completed_steps],
@@ -165,13 +234,15 @@ module Ocak
165
234
 
166
235
  def write_step_output(issue_number, idx, agent, output)
167
236
  return if output.to_s.empty?
237
+ return unless issue_number.to_s.match?(/\A\d+\z/)
168
238
 
169
239
  safe_agent = agent.to_s.gsub(/[^a-zA-Z0-9_-]/, '')
170
240
  dir = File.join(@config.project_dir, '.ocak', 'logs', "issue-#{issue_number}")
171
241
  FileUtils.mkdir_p(dir)
172
242
  File.write(File.join(dir, "step-#{idx}-#{safe_agent}.md"), output)
173
- rescue StandardError
174
- nil # sidecar write failures must never crash the pipeline
243
+ rescue StandardError => e
244
+ @logger&.debug("Step output write failed: #{e.message}")
245
+ nil
175
246
  end
176
247
 
177
248
  def check_step_failure(ctx)
@@ -221,29 +292,9 @@ module Ocak
221
292
  end
222
293
 
223
294
  def run_final_verification(issue_number, logger:, claude:, chdir:)
224
- return nil unless @config.test_command || @config.lint_check_command
225
-
226
- logger.info('--- Final verification ---')
227
- post_step_comment(issue_number, "\u{1F504} **Phase: final-verify** (verification)")
228
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
229
- result = run_final_checks(logger, chdir: chdir)
230
-
231
- unless result[:success]
232
- logger.warn('Final checks failed, attempting fix...')
233
- post_step_comment(issue_number, "\u{26A0}\u{FE0F} **Final verification failed** \u2014 attempting auto-fix...")
234
- fix_prompt = "Fix these test/lint failures:\n\n" \
235
- "<verification_output>\n#{result[:output]}\n</verification_output>"
236
- claude.run_agent('implementer', fix_prompt, chdir: chdir)
237
- result = run_final_checks(logger, chdir: chdir)
238
- end
239
-
240
- duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time).round
241
- if result[:success]
242
- post_step_comment(issue_number, "\u{2705} **Phase: final-verify** completed \u{2014} #{duration}s")
243
- nil
244
- else
245
- post_step_comment(issue_number, "\u{274C} **Phase: final-verify** failed \u{2014} #{duration}s")
246
- { success: false, phase: 'final-verify', output: result[:output] }
295
+ run_verification_with_retry(logger: logger, claude: claude, chdir: chdir,
296
+ model: @verification_model) do |body|
297
+ post_step_comment(issue_number, body)
247
298
  end
248
299
  end
249
300
 
@@ -258,8 +309,9 @@ module Ocak
258
309
  def save_report(report, issue_number, success:, failed_phase: nil)
259
310
  report.finish(success: success, failed_phase: failed_phase)
260
311
  report.save(issue_number, project_dir: @config.project_dir)
261
- rescue StandardError
262
- nil # report save failures must never crash the pipeline
312
+ rescue StandardError => e
313
+ @logger&.debug("Report save failed: #{e.message}")
314
+ nil
263
315
  end
264
316
 
265
317
  def pipeline_state
@@ -274,8 +326,12 @@ module Ocak
274
326
  nil
275
327
  end
276
328
 
329
+ def active_steps
330
+ @custom_steps || @config.steps
331
+ end
332
+
277
333
  def post_pipeline_start_comment(issue_number, state)
278
- total = @config.steps.size
334
+ total = active_steps.size
279
335
  conditional = conditional_step_count(state)
280
336
  post_step_comment(issue_number,
281
337
  "\u{1F680} **Pipeline started** \u2014 complexity: `#{state[:complexity]}` " \
@@ -283,7 +339,7 @@ module Ocak
283
339
  end
284
340
 
285
341
  def post_pipeline_summary_comment(issue_number, state, duration, success:, failed_phase: nil)
286
- total = @config.steps.size
342
+ total = active_steps.size
287
343
  cost = format('%.2f', state[:total_cost])
288
344
 
289
345
  if success
@@ -298,7 +354,7 @@ module Ocak
298
354
  end
299
355
 
300
356
  def conditional_step_count(state)
301
- @config.steps.count do |step|
357
+ active_steps.count do |step|
302
358
  step = symbolize(step)
303
359
  step[:condition] ||
304
360
  (step[:complexity] == 'full' && state[:complexity] == 'simple') ||
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'failure_reporting'
3
4
  require_relative 'merge_orchestration'
4
5
  require_relative 'pipeline_executor'
5
6
  require_relative 'process_registry'
@@ -8,6 +9,7 @@ require_relative 'reready_processor'
8
9
 
9
10
  module Ocak
10
11
  class PipelineRunner
12
+ include FailureReporting
11
13
  include MergeOrchestration
12
14
 
13
15
  attr_reader :registry
@@ -82,9 +84,7 @@ module Ocak
82
84
  elsif result[:success]
83
85
  handle_single_success(issue_number, result, logger: logger, claude: claude, issues: issues)
84
86
  else
85
- issues.transition(issue_number, from: @config.label_in_progress, to: @config.label_failed)
86
- issues.comment(issue_number,
87
- "Pipeline failed at phase: #{result[:phase]}\n\n```\n#{result[:output][0..1000]}\n```")
87
+ report_pipeline_failure(issue_number, result, issues: issues, config: @config)
88
88
  logger.error("Issue ##{issue_number} failed at phase: #{result[:phase]}")
89
89
  end
90
90
  end
@@ -114,7 +114,11 @@ module Ocak
114
114
  break if @options[:once]
115
115
 
116
116
  logger.info("Sleeping #{@config.poll_interval}s...")
117
- sleep @config.poll_interval
117
+ @config.poll_interval.times do
118
+ break if @shutting_down
119
+
120
+ sleep 1
121
+ end
118
122
  end
119
123
  end
120
124
 
@@ -141,7 +145,7 @@ module Ocak
141
145
  end
142
146
 
143
147
  def run_batch(batch_issues, logger:, issues:)
144
- worktrees = WorktreeManager.new(config: @config)
148
+ worktrees = WorktreeManager.new(config: @config, logger: logger)
145
149
 
146
150
  threads = batch_issues.map do |issue|
147
151
  Thread.new { process_one_issue(issue, worktrees: worktrees, issues: issues) }
@@ -165,6 +169,9 @@ module Ocak
165
169
  rescue StandardError => e
166
170
  logger.warn("Failed to clean worktree for ##{result[:issue_number]}: #{e.message}")
167
171
  end
172
+
173
+ programming_error = results.find { |r| r[:programming_error] }&.dig(:programming_error)
174
+ raise programming_error if programming_error
168
175
  end
169
176
 
170
177
  def process_one_issue(issue, worktrees:, issues:)
@@ -187,9 +194,11 @@ module Ocak
187
194
  build_issue_result(result, issue_number: issue_number, worktree: worktree, issues: issues,
188
195
  logger: logger)
189
196
  rescue StandardError => e
190
- logger.error("Unexpected error: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
191
- issues.transition(issue_number, from: @config.label_in_progress, to: @config.label_failed)
192
- { issue_number: issue_number, success: false, worktree: worktree, error: e.message }
197
+ handle_process_error(e, issue_number: issue_number, logger: logger, issues: issues)
198
+ result = { issue_number: issue_number, success: false, worktree: worktree, error: e.message }
199
+ # NameError includes NoMethodError
200
+ result[:programming_error] = e if e.is_a?(NameError) || e.is_a?(TypeError)
201
+ result
193
202
  ensure
194
203
  @active_mutex.synchronize { @active_issues.delete(issue_number) }
195
204
  end
@@ -203,9 +212,7 @@ module Ocak
203
212
  { issue_number: issue_number, success: true, worktree: worktree,
204
213
  audit_blocked: result[:audit_blocked], audit_output: result[:audit_output] }
205
214
  else
206
- issues.transition(issue_number, from: @config.label_in_progress, to: @config.label_failed)
207
- issues.comment(issue_number,
208
- "Pipeline failed at phase: #{result[:phase]}\n\n```\n#{result[:output][0..1000]}\n```")
215
+ report_pipeline_failure(issue_number, result, issues: issues, config: @config)
209
216
  { issue_number: issue_number, success: false, worktree: worktree }
210
217
  end
211
218
  end
@@ -226,7 +233,7 @@ module Ocak
226
233
  end
227
234
 
228
235
  def cleanup_stale_worktrees(logger)
229
- worktrees = WorktreeManager.new(config: @config)
236
+ worktrees = WorktreeManager.new(config: @config, logger: logger)
230
237
  removed = worktrees.clean_stale
231
238
  removed.each { |path| logger.info("Cleaned stale worktree: #{path}") }
232
239
  rescue StandardError => e
@@ -259,6 +266,17 @@ module Ocak
259
266
  @registry.kill_all
260
267
  end
261
268
 
269
+ def handle_process_error(error, issue_number:, logger:, issues:)
270
+ logger.error("Unexpected #{error.class}: #{error.message}\n#{error.backtrace&.first(5)&.join("\n")}")
271
+ logger.debug("Full backtrace:\n#{error.backtrace&.join("\n")}")
272
+ issues.transition(issue_number, from: @config.label_in_progress, to: @config.label_failed)
273
+ begin
274
+ issues.comment(issue_number, "Unexpected #{error.class}: #{error.message}")
275
+ rescue StandardError
276
+ nil
277
+ end
278
+ end
279
+
262
280
  def handle_interrupted_issue(issue_number, worktree_path, step_name, logger:, issues:)
263
281
  if worktree_path
264
282
  GitUtils.commit_changes(chdir: worktree_path,
@@ -5,8 +5,9 @@ require 'fileutils'
5
5
 
6
6
  module Ocak
7
7
  class PipelineState
8
- def initialize(log_dir:)
8
+ def initialize(log_dir:, logger: nil)
9
9
  @log_dir = log_dir
10
+ @logger = logger
10
11
  end
11
12
 
12
13
  def save(issue_number, completed_steps:, worktree_path: nil, branch: nil)
@@ -18,6 +19,10 @@ module Ocak
18
19
  branch: branch,
19
20
  updated_at: Time.now.iso8601
20
21
  }))
22
+ rescue StandardError => e
23
+ @logger&.warn("Pipeline state save failed for issue ##{issue_number}: #{e.message}") ||
24
+ warn("Pipeline state save failed for issue ##{issue_number}: #{e.message}")
25
+ nil
21
26
  end
22
27
 
23
28
  def load(issue_number)
@@ -25,7 +30,7 @@ module Ocak
25
30
  return nil unless File.exist?(path)
26
31
 
27
32
  JSON.parse(File.read(path), symbolize_names: true)
28
- rescue JSON::ParserError => e
33
+ rescue ArgumentError, JSON::ParserError => e
29
34
  warn("Failed to parse pipeline state for issue ##{issue_number}: #{e.message}")
30
35
  nil
31
36
  end
@@ -33,6 +38,8 @@ module Ocak
33
38
  def delete(issue_number)
34
39
  path = state_path(issue_number)
35
40
  FileUtils.rm_f(path)
41
+ rescue ArgumentError
42
+ nil
36
43
  end
37
44
 
38
45
  def list
@@ -47,6 +54,8 @@ module Ocak
47
54
  private
48
55
 
49
56
  def state_path(issue_number)
57
+ raise ArgumentError, "Invalid issue number: #{issue_number}" unless issue_number.to_s.match?(/\A\d+\z/)
58
+
50
59
  File.join(@log_dir, "issue-#{issue_number}-state.json")
51
60
  end
52
61
  end
data/lib/ocak/planner.rb CHANGED
@@ -11,8 +11,7 @@ module Ocak
11
11
  'verify' => 'Review the changes for GitHub issue #%<issue>s. Run: git diff main',
12
12
  'security' => 'Security review changes for GitHub issue #%<issue>s. Run: git diff main',
13
13
  'document' => 'Add documentation for changes in GitHub issue #%<issue>s',
14
- 'merge' => 'Create a PR, merge it, and close issue #%<issue>s',
15
- 'create_pr' => 'Create a PR, merge it, and close issue #%<issue>s'
14
+ 'merge' => 'Create a PR, merge it, and close issue #%<issue>s'
16
15
  }.freeze
17
16
 
18
17
  def build_step_prompt(role, issue_number, review_output)
@@ -5,6 +5,8 @@ require 'open3'
5
5
  module Ocak
6
6
  # Runs a subprocess with streaming line output and timeout support.
7
7
  module ProcessRunner
8
+ KILL_GRACE_PERIOD = 2
9
+
8
10
  FailedStatus = Struct.new(:success?) do
9
11
  def self.instance = new(false)
10
12
  end
@@ -54,9 +56,9 @@ module Ocak
54
56
 
55
57
  def kill_process(pid)
56
58
  Process.kill('TERM', pid)
57
- sleep 2
59
+ sleep KILL_GRACE_PERIOD
58
60
  Process.kill('KILL', pid)
59
- rescue Errno::ESRCH => e
61
+ rescue Errno::ESRCH, Errno::EPERM => e
60
62
  warn("Process already exited during kill: #{e.message}")
61
63
  nil
62
64
  end
@@ -101,6 +101,9 @@ module Ocak
101
101
 
102
102
  _, _, status = Open3.capture3(*Shellwords.shellsplit(cmd), chdir: @config.project_dir)
103
103
  status.success?
104
+ rescue ArgumentError => e
105
+ @logger&.warn("Invalid shell command in config: #{cmd.inspect} (#{e.message})")
106
+ false
104
107
  end
105
108
 
106
109
  def handle_result(pr_number, success)
@@ -133,8 +136,7 @@ module Ocak
133
136
  end
134
137
 
135
138
  def cleanup
136
- _, stderr, status = Open3.capture3('git', 'checkout', 'main', chdir: @config.project_dir)
137
- @logger.warn("Cleanup checkout to main failed: #{stderr}") unless status.success?
139
+ GitUtils.checkout_main(chdir: @config.project_dir, logger: @logger)
138
140
  end
139
141
 
140
142
  def build_feedback_prompt(feedback)
@@ -41,6 +41,8 @@ module Ocak
41
41
  end
42
42
 
43
43
  def save(issue_number, project_dir:)
44
+ return nil unless issue_number.to_s.match?(/\A\d+\z/)
45
+
44
46
  dir = File.join(project_dir, REPORTS_DIR)
45
47
  FileUtils.mkdir_p(dir)
46
48
 
@@ -2,21 +2,26 @@
2
2
 
3
3
  module Ocak
4
4
  # Shared comment-posting helpers for pipeline steps.
5
- # Includers must provide an @issues instance variable (IssueFetcher or nil).
5
+ # Includers typically provide an @issues instance variable (IssueFetcher or nil).
6
+ # All methods accept an optional `issues:` keyword to override @issues, allowing
7
+ # callers like Hiz to pass issues from a different source (e.g., state.issues).
6
8
  module StepComments
7
- def post_step_comment(issue_number, body)
8
- @issues&.comment(issue_number, body)
9
- rescue StandardError
9
+ def post_step_comment(issue_number, body, issues: @issues)
10
+ issues&.comment(issue_number, body)
11
+ rescue StandardError => e
12
+ @logger&.debug("Step comment failed: #{e.message}")
10
13
  nil
11
14
  end
12
15
 
13
- def post_step_completion_comment(issue_number, role, result)
16
+ def post_step_completion_comment(issue_number, role, result, issues: @issues)
14
17
  duration = (result.duration_ms.to_f / 1000).round
15
18
  cost = format('%.3f', result.cost_usd.to_f)
16
19
  if result.success?
17
- post_step_comment(issue_number, "\u{2705} **Phase: #{role}** completed \u2014 #{duration}s | $#{cost}")
20
+ post_step_comment(issue_number, "\u{2705} **Phase: #{role}** completed \u2014 #{duration}s | $#{cost}",
21
+ issues: issues)
18
22
  else
19
- post_step_comment(issue_number, "\u{274C} **Phase: #{role}** failed \u2014 #{duration}s | $#{cost}")
23
+ post_step_comment(issue_number, "\u{274C} **Phase: #{role}** failed \u2014 #{duration}s | $#{cost}",
24
+ issues: issues)
20
25
  end
21
26
  end
22
27
  end
@@ -157,7 +157,12 @@ module Ocak
157
157
  result_text = extract_tool_text(block['content'])
158
158
  passed = detect_test_pass(result_text)
159
159
  cmd_label = command[TEST_CMD_PATTERN] || 'test'
160
- @logger.info("[TEST] #{passed ? 'PASS' : 'FAIL'} (#{cmd_label})", agent: @agent_name)
160
+ status_label = case passed
161
+ when true then 'PASS'
162
+ when false then 'FAIL'
163
+ else 'UNKNOWN'
164
+ end
165
+ @logger.info("[TEST] #{status_label} (#{cmd_label})", agent: @agent_name)
161
166
 
162
167
  { category: :tool_result, is_test_result: true, passed: passed, command: cmd_label }
163
168
  end
@@ -193,7 +198,7 @@ module Ocak
193
198
  return false if output.match?(/FAIL/i) && !output.match?(/0 failed/i)
194
199
  return true if output.match?(/passed/i) && !output.match?(/failed/i)
195
200
 
196
- true # no obvious failure signal
201
+ nil # no recognized pattern — unknown result
197
202
  end
198
203
  end
199
204
  end
@@ -12,6 +12,7 @@ stack:
12
12
  <%- end -%>
13
13
  <%- if lint_command -%>
14
14
  lint_command: "<%= lint_command %>"
15
+ # lint_check_command: "<%= lint_command %>" # Explicit check-only lint command (no auto-fix flags)
15
16
  <%- end -%>
16
17
  <%- if setup_command -%>
17
18
  setup_command: "<%= setup_command %>"