ocak 0.4.0 → 0.5.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +27 -0
  3. data/lib/ocak/agent_generator.rb +2 -1
  4. data/lib/ocak/batch_processing.rb +102 -0
  5. data/lib/ocak/claude_runner.rb +12 -8
  6. data/lib/ocak/cli.rb +13 -0
  7. data/lib/ocak/command_runner.rb +39 -0
  8. data/lib/ocak/commands/hiz.rb +8 -7
  9. data/lib/ocak/commands/init.rb +5 -0
  10. data/lib/ocak/commands/issue/close.rb +37 -0
  11. data/lib/ocak/commands/issue/create.rb +59 -0
  12. data/lib/ocak/commands/issue/edit.rb +31 -0
  13. data/lib/ocak/commands/issue/list.rb +43 -0
  14. data/lib/ocak/commands/issue/view.rb +58 -0
  15. data/lib/ocak/commands/resume.rb +4 -3
  16. data/lib/ocak/commands/status.rb +20 -0
  17. data/lib/ocak/config.rb +4 -0
  18. data/lib/ocak/failure_reporting.rb +3 -2
  19. data/lib/ocak/git_utils.rb +18 -11
  20. data/lib/ocak/instance_builders.rb +50 -0
  21. data/lib/ocak/issue_backend.rb +31 -0
  22. data/lib/ocak/local_issue_fetcher.rb +165 -0
  23. data/lib/ocak/local_merge_manager.rb +104 -0
  24. data/lib/ocak/merge_manager.rb +31 -32
  25. data/lib/ocak/merge_orchestration.rb +8 -2
  26. data/lib/ocak/parallel_execution.rb +36 -0
  27. data/lib/ocak/pipeline_executor.rb +9 -183
  28. data/lib/ocak/pipeline_runner.rb +15 -180
  29. data/lib/ocak/planner.rb +1 -1
  30. data/lib/ocak/project_key.rb +38 -0
  31. data/lib/ocak/reready_processor.rb +11 -11
  32. data/lib/ocak/run_report.rb +5 -2
  33. data/lib/ocak/shutdown_handling.rb +67 -0
  34. data/lib/ocak/state_management.rb +104 -0
  35. data/lib/ocak/step_execution.rb +66 -0
  36. data/lib/ocak/templates/agents/auditor.md.erb +38 -9
  37. data/lib/ocak/templates/agents/implementer.md.erb +32 -8
  38. data/lib/ocak/templates/agents/merger.md.erb +12 -5
  39. data/lib/ocak/templates/agents/pipeline.md.erb +4 -0
  40. data/lib/ocak/templates/agents/reviewer.md.erb +2 -2
  41. data/lib/ocak/templates/agents/security_reviewer.md.erb +11 -0
  42. data/lib/ocak/templates/ocak.yml.erb +15 -0
  43. data/lib/ocak/verification.rb +6 -1
  44. data/lib/ocak/worktree_manager.rb +2 -0
  45. data/lib/ocak.rb +1 -1
  46. metadata +17 -1
@@ -7,14 +7,18 @@ require_relative 'run_report'
7
7
  require_relative 'verification'
8
8
  require_relative 'planner'
9
9
  require_relative 'step_comments'
10
+ require_relative 'state_management'
11
+ require_relative 'step_execution'
12
+ require_relative 'parallel_execution'
10
13
 
11
14
  module Ocak
12
15
  class PipelineExecutor
13
16
  include Verification
14
17
  include Planner
15
18
  include StepComments
16
-
17
- StepContext = Struct.new(:issue_number, :idx, :role, :result, :state, :logger, :chdir)
19
+ include StateManagement
20
+ include StepExecution
21
+ include ParallelExecution
18
22
 
19
23
  attr_writer :issues
20
24
 
@@ -26,11 +30,13 @@ module Ocak
26
30
 
27
31
  def run_pipeline(issue_number, logger:, claude:, chdir: nil, skip_steps: [], complexity: 'full', # rubocop:disable Metrics/ParameterLists
28
32
  steps: nil, verification_model: nil,
29
- post_start_comment: true, post_summary_comment: true)
33
+ post_start_comment: true, post_summary_comment: true,
34
+ skip_merge: false)
30
35
  @logger = logger
31
36
  @custom_steps = steps
32
37
  @verification_model = verification_model
33
38
  @post_summary_comment = post_summary_comment
39
+ @skip_merge = skip_merge
34
40
  chdir ||= @config.project_dir
35
41
  logger.info("=== Starting pipeline for issue ##{issue_number} (#{complexity}) ===")
36
42
 
@@ -120,55 +126,6 @@ module Ocak
120
126
  nil
121
127
  end
122
128
 
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]
129
-
130
- group << [step, idx]
131
- idx += 1
132
- end
133
- group
134
- end
135
-
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
145
- end
146
- end
147
-
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
163
- end
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)
170
- end
171
-
172
129
  def check_shutdown(state, logger)
173
130
  return false unless @shutdown_check&.call
174
131
 
@@ -176,121 +133,6 @@ module Ocak
176
133
  state[:interrupted] = true
177
134
  end
178
135
 
179
- def handle_already_completed(idx, role, skip_steps, logger)
180
- return false unless skip_steps.include?(idx)
181
-
182
- logger.info("Skipping #{role} (already completed)")
183
- true
184
- end
185
-
186
- def record_skipped_step(issue_number, state, idx, agent, role, reason)
187
- post_step_comment(issue_number, "\u{23ED}\u{FE0F} **Skipping #{role}** \u2014 #{reason}")
188
- state[:report].record_step(index: idx, agent: agent, role: role, status: 'skipped', skip_reason: reason)
189
- state[:steps_skipped] += 1
190
- end
191
-
192
- def execute_step(step, issue_number, review_output, logger:, claude:, chdir:)
193
- agent = step[:agent].to_s
194
- role = step[:role].to_s
195
- logger.info("--- Phase: #{role} (#{agent}) ---")
196
- post_step_comment(issue_number, "\u{1F504} **Phase: #{role}** (#{agent})")
197
- prompt = build_step_prompt(role, issue_number, review_output)
198
- opts = { chdir: chdir }
199
- opts[:model] = step[:model].to_s if step[:model]
200
- claude.run_agent(agent.tr('_', '-'), prompt, **opts)
201
- end
202
-
203
- def record_step_result(ctx, mutex: nil)
204
- sync(mutex) { accumulate_state(ctx) }
205
- save_step_progress(ctx)
206
- write_step_output(ctx.issue_number, ctx.idx, ctx.role, ctx.result.output)
207
- post_step_completion_comment(ctx.issue_number, ctx.role, ctx.result)
208
-
209
- check_step_failure(ctx) || check_cost_budget(ctx.state, ctx.logger)
210
- end
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
-
228
- def save_step_progress(ctx)
229
- pipeline_state.save(ctx.issue_number,
230
- completed_steps: ctx.state[:completed_steps],
231
- worktree_path: ctx.chdir,
232
- branch: current_branch(ctx.chdir, logger: ctx.logger))
233
- end
234
-
235
- def write_step_output(issue_number, idx, agent, output)
236
- return if output.to_s.empty?
237
- return unless issue_number.to_s.match?(/\A\d+\z/)
238
-
239
- safe_agent = agent.to_s.gsub(/[^a-zA-Z0-9_-]/, '')
240
- dir = File.join(@config.project_dir, '.ocak', 'logs', "issue-#{issue_number}")
241
- FileUtils.mkdir_p(dir)
242
- File.write(File.join(dir, "step-#{idx}-#{safe_agent}.md"), output)
243
- rescue StandardError => e
244
- @logger&.debug("Step output write failed: #{e.message}")
245
- nil
246
- end
247
-
248
- def check_step_failure(ctx)
249
- return nil if ctx.result.success? || !%w[implement merge].include?(ctx.role)
250
-
251
- ctx.logger.error("#{ctx.role} failed")
252
- { success: false, phase: ctx.role, output: ctx.result.output }
253
- end
254
-
255
- def check_cost_budget(state, logger)
256
- return nil unless @config.cost_budget && state[:total_cost] > @config.cost_budget
257
-
258
- cost = format('%.2f', state[:total_cost])
259
- budget = format('%.2f', @config.cost_budget)
260
- logger.error("Cost budget exceeded ($#{cost}/$#{budget})")
261
- { success: false, phase: 'budget', output: "Cost budget exceeded: $#{cost}" }
262
- end
263
-
264
- def skip_reason(step, state)
265
- condition = step[:condition]
266
-
267
- return 'audit found blocking issues' if step[:role].to_s == 'merge' && @config.audit_mode && state[:audit_blocked]
268
- return 'manual review mode' if step[:role].to_s == 'merge' && @config.manual_review
269
- return 'fast-track issue (simple complexity)' if step[:complexity] == 'full' && state[:complexity] == 'simple'
270
- if condition == 'has_findings' && !state[:last_review_output]&.include?("\u{1F534}")
271
- return 'no blocking findings from review'
272
- end
273
- return 'no fixes were made' if condition == 'had_fixes' && !state[:had_fixes]
274
-
275
- nil
276
- end
277
-
278
- def update_pipeline_state(role, result, state)
279
- case role
280
- when 'review', 'verify', 'security', 'audit'
281
- state[:last_review_output] = result.output
282
- if role == 'audit'
283
- state[:audit_output] = result.output
284
- state[:audit_blocked] = !result.success? || result.output.to_s.match?(/BLOCK|🔴/)
285
- end
286
- when 'fix'
287
- state[:had_fixes] = true
288
- state[:last_review_output] = nil
289
- when 'implement'
290
- state[:last_review_output] = nil
291
- end
292
- end
293
-
294
136
  def run_final_verification(issue_number, logger:, claude:, chdir:)
295
137
  run_verification_with_retry(logger: logger, claude: claude, chdir: chdir,
296
138
  model: @verification_model) do |body|
@@ -298,22 +140,6 @@ module Ocak
298
140
  end
299
141
  end
300
142
 
301
- def log_cost_summary(total_cost, logger)
302
- return if total_cost.zero?
303
-
304
- budget = @config.cost_budget
305
- budget_str = budget ? " / $#{format('%.2f', budget)} budget" : ''
306
- logger.info("Pipeline cost: $#{format('%.4f', total_cost)}#{budget_str}")
307
- end
308
-
309
- def save_report(report, issue_number, success:, failed_phase: nil)
310
- report.finish(success: success, failed_phase: failed_phase)
311
- report.save(issue_number, project_dir: @config.project_dir)
312
- rescue StandardError => e
313
- @logger&.debug("Report save failed: #{e.message}")
314
- nil
315
- end
316
-
317
143
  def pipeline_state
318
144
  @pipeline_state ||= PipelineState.new(log_dir: File.join(@config.project_dir, @config.log_dir))
319
145
  end
@@ -1,16 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'batch_processing'
3
4
  require_relative 'failure_reporting'
5
+ require_relative 'instance_builders'
4
6
  require_relative 'merge_orchestration'
5
7
  require_relative 'pipeline_executor'
6
8
  require_relative 'process_registry'
9
+ require_relative 'shutdown_handling'
7
10
  require_relative 'git_utils'
11
+ require_relative 'issue_backend'
12
+ require_relative 'local_merge_manager'
8
13
  require_relative 'reready_processor'
9
14
 
10
15
  module Ocak
11
16
  class PipelineRunner
17
+ include BatchProcessing
12
18
  include FailureReporting
19
+ include InstanceBuilders
13
20
  include MergeOrchestration
21
+ include ShutdownHandling
14
22
 
15
23
  attr_reader :registry
16
24
 
@@ -31,41 +39,23 @@ module Ocak
31
39
  @options[:single] ? run_single(@options[:single]) : run_loop
32
40
  end
33
41
 
34
- def run_pipeline(issue_number, logger:, claude:, chdir: nil, skip_steps: [], complexity: 'full')
42
+ def run_pipeline(issue_number, logger:, claude:, chdir: nil, skip_steps: [], complexity: 'full', # rubocop:disable Metrics/ParameterLists
43
+ skip_merge: false)
35
44
  @executor.run_pipeline(issue_number, logger: logger, claude: claude, chdir: chdir,
36
- skip_steps: skip_steps, complexity: complexity)
37
- end
38
-
39
- def shutdown!
40
- count = @active_mutex.synchronize { @shutdown_count += 1 }
41
-
42
- if count >= 2
43
- force_shutdown!
44
- else
45
- graceful_shutdown!
46
- end
45
+ skip_steps: skip_steps, complexity: complexity,
46
+ skip_merge: skip_merge)
47
47
  end
48
48
 
49
49
  def shutting_down?
50
50
  @shutting_down
51
51
  end
52
52
 
53
- def print_shutdown_summary
54
- issues = @active_mutex.synchronize { @interrupted_issues.dup }
55
- return if issues.empty?
56
-
57
- warn "\nInterrupted issues:"
58
- issues.each do |issue_number|
59
- warn " - Issue ##{issue_number}: ocak resume --issue #{issue_number}"
60
- end
61
- end
62
-
63
53
  private
64
54
 
65
55
  def run_single(issue_number)
66
56
  logger = build_logger(issue_number: issue_number)
67
57
  claude = build_claude(logger)
68
- issues = IssueFetcher.new(config: @config)
58
+ issues = IssueBackend.build(config: @config)
69
59
  ensure_labels(issues, logger)
70
60
  @executor.issues = issues
71
61
  logger.info("Running single issue mode for ##{issue_number}")
@@ -84,14 +74,14 @@ module Ocak
84
74
  elsif result[:success]
85
75
  handle_single_success(issue_number, result, logger: logger, claude: claude, issues: issues)
86
76
  else
87
- report_pipeline_failure(issue_number, result, issues: issues, config: @config)
77
+ report_pipeline_failure(issue_number, result, issues: issues, config: @config, logger: logger)
88
78
  logger.error("Issue ##{issue_number} failed at phase: #{result[:phase]}")
89
79
  end
90
80
  end
91
81
 
92
82
  def run_loop
93
83
  logger = build_logger
94
- issues = IssueFetcher.new(config: @config, logger: logger)
84
+ issues = IssueBackend.build(config: @config, logger: logger)
95
85
  ensure_labels(issues, logger)
96
86
  @executor.issues = issues
97
87
  cleanup_stale_worktrees(logger)
@@ -122,101 +112,6 @@ module Ocak
122
112
  end
123
113
  end
124
114
 
125
- def process_issues(ready_issues, logger:, issues:)
126
- if ready_issues.size > @config.max_issues_per_run
127
- logger.warn("Capping to #{@config.max_issues_per_run} issues (found #{ready_issues.size})")
128
- ready_issues = ready_issues.first(@config.max_issues_per_run)
129
- end
130
-
131
- claude = build_claude(logger)
132
- batches = @executor.plan_batches(ready_issues, logger: logger, claude: claude)
133
-
134
- batches.each_with_index do |batch, idx|
135
- batch_issues = batch['issues'][0...@config.max_parallel]
136
- logger.info("Running batch #{idx + 1}/#{batches.size} (#{batch_issues.size} issues)")
137
-
138
- if @options[:dry_run]
139
- batch_issues.each { |i| logger.info("[DRY RUN] Would process issue ##{i['number']}: #{i['title']}") }
140
- next
141
- end
142
-
143
- run_batch(batch_issues, logger: logger, issues: issues)
144
- end
145
- end
146
-
147
- def run_batch(batch_issues, logger:, issues:)
148
- worktrees = WorktreeManager.new(config: @config, logger: logger)
149
-
150
- threads = batch_issues.map do |issue|
151
- Thread.new { process_one_issue(issue, worktrees: worktrees, issues: issues) }
152
- end
153
- results = threads.map(&:value)
154
-
155
- unless @shutting_down
156
- merger = MergeManager.new(
157
- config: @config, claude: build_claude(logger), logger: logger, issues: issues, watch: @watch_formatter
158
- )
159
- results.select { |r| r[:success] }.each do |result|
160
- merge_completed_issue(result, merger: merger, issues: issues, logger: logger)
161
- end
162
- end
163
-
164
- results.each do |result|
165
- next unless result[:worktree]
166
- next if result[:interrupted]
167
-
168
- worktrees.remove(result[:worktree])
169
- rescue StandardError => e
170
- logger.warn("Failed to clean worktree for ##{result[:issue_number]}: #{e.message}")
171
- end
172
-
173
- programming_error = results.find { |r| r[:programming_error] }&.dig(:programming_error)
174
- raise programming_error if programming_error
175
- end
176
-
177
- def process_one_issue(issue, worktrees:, issues:)
178
- issue_number = issue['number']
179
- logger = build_logger(issue_number: issue_number)
180
- claude = build_claude(logger)
181
- worktree = nil
182
-
183
- @active_mutex.synchronize do
184
- @active_issues << issue_number
185
- end
186
- issues.transition(issue_number, from: @config.label_ready, to: @config.label_in_progress)
187
- worktree = worktrees.create(issue_number, setup_command: @config.setup_command)
188
- logger.info("Created worktree at #{worktree.path} (branch: #{worktree.branch})")
189
-
190
- complexity = @options[:fast] ? 'simple' : issue.fetch('complexity', 'full')
191
- result = run_pipeline(issue_number, logger: logger, claude: claude, chdir: worktree.path,
192
- complexity: complexity)
193
-
194
- build_issue_result(result, issue_number: issue_number, worktree: worktree, issues: issues,
195
- logger: logger)
196
- rescue StandardError => e
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
202
- ensure
203
- @active_mutex.synchronize { @active_issues.delete(issue_number) }
204
- end
205
-
206
- def build_issue_result(result, issue_number:, worktree:, issues:, logger: nil)
207
- if result[:interrupted]
208
- handle_interrupted_issue(issue_number, worktree&.path, result[:phase],
209
- logger: logger || build_logger(issue_number: issue_number), issues: issues)
210
- { issue_number: issue_number, success: false, worktree: worktree, interrupted: true }
211
- elsif result[:success]
212
- { issue_number: issue_number, success: true, worktree: worktree,
213
- audit_blocked: result[:audit_blocked], audit_output: result[:audit_output] }
214
- else
215
- report_pipeline_failure(issue_number, result, issues: issues, config: @config)
216
- { issue_number: issue_number, success: false, worktree: worktree }
217
- end
218
- end
219
-
220
115
  def process_reready_prs(logger:, issues:)
221
116
  reready = issues.fetch_reready_prs
222
117
  return if reready.empty?
@@ -231,65 +126,5 @@ module Ocak
231
126
  processor.process(pr)
232
127
  end
233
128
  end
234
-
235
- def cleanup_stale_worktrees(logger)
236
- worktrees = WorktreeManager.new(config: @config, logger: logger)
237
- removed = worktrees.clean_stale
238
- removed.each { |path| logger.info("Cleaned stale worktree: #{path}") }
239
- rescue StandardError => e
240
- logger.warn("Stale worktree cleanup failed: #{e.message}")
241
- end
242
-
243
- def ensure_labels(issues, logger)
244
- issues.ensure_labels(@config.all_labels)
245
- rescue StandardError => e
246
- logger.warn("Failed to ensure labels: #{e.message}")
247
- end
248
-
249
- def build_logger(issue_number: nil)
250
- PipelineLogger.new(log_dir: File.join(@config.project_dir, @config.log_dir),
251
- issue_number: issue_number, log_level: @options.fetch(:log_level, :normal))
252
- end
253
-
254
- def build_claude(logger)
255
- ClaudeRunner.new(config: @config, logger: logger, watch: @watch_formatter, registry: @registry)
256
- end
257
-
258
- def graceful_shutdown!
259
- @shutting_down = true
260
- warn "\nGraceful shutdown initiated — finishing current agent step(s)..."
261
- end
262
-
263
- def force_shutdown!
264
- @shutting_down = true
265
- warn "\nForce shutdown — killing active processes..."
266
- @registry.kill_all
267
- end
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
-
280
- def handle_interrupted_issue(issue_number, worktree_path, step_name, logger:, issues:)
281
- if worktree_path
282
- GitUtils.commit_changes(chdir: worktree_path,
283
- message: "wip: pipeline interrupted after step #{step_name} for issue ##{issue_number}",
284
- logger: logger)
285
- end
286
- issues&.transition(issue_number, from: @config.label_in_progress, to: @config.label_ready)
287
- issues&.comment(issue_number,
288
- "\u{26A0}\u{FE0F} Pipeline interrupted after #{step_name}. " \
289
- "Resume with `ocak resume --issue #{issue_number}`.")
290
- @active_mutex.synchronize { @interrupted_issues << issue_number }
291
- rescue StandardError => e
292
- logger.warn("Failed to handle interrupted issue ##{issue_number}: #{e.message}")
293
- end
294
129
  end
295
130
  end
data/lib/ocak/planner.rb CHANGED
@@ -30,7 +30,7 @@ module Ocak
30
30
  issue_json = JSON.generate(issues.map { |i| { number: i['number'], title: i['title'] } })
31
31
  result = claude.run_agent(
32
32
  'planner',
33
- "Analyze these issues and output parallelization batches as JSON:\n\n#{issue_json}"
33
+ "Analyze these issues and output parallelization batches as JSON:\n\n<issue_data>\n#{issue_json}\n</issue_data>"
34
34
  )
35
35
 
36
36
  unless result.success?
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Ocak
6
+ module ProjectKey
7
+ class NoRemoteError < StandardError; end
8
+
9
+ # Resolve owner/repo from git remote origin.
10
+ # Returns a string like "owner/repo".
11
+ # Raises NoRemoteError if no remote or URL is unparseable.
12
+ def self.resolve(dir = Dir.pwd)
13
+ url = fetch_remote_url(dir)
14
+ parse_url(url)
15
+ end
16
+
17
+ def self.fetch_remote_url(dir)
18
+ stdout, _stderr, status = Open3.capture3('git', 'remote', 'get-url', 'origin', chdir: dir)
19
+ raise NoRemoteError, "No git remote 'origin' found in #{dir}" unless status.success?
20
+
21
+ stdout.strip
22
+ end
23
+ private_class_method :fetch_remote_url
24
+
25
+ def self.parse_url(url)
26
+ # Matches both SSH and HTTPS patterns:
27
+ # git@github.com:owner/repo.git → owner, repo
28
+ # https://github.com/owner/repo.git → owner, repo
29
+ # https://github.com/owner/repo → owner, repo
30
+ if (match = url.match(%r{[/:]([\w.-]+)/([\w.-]+?)(?:\.git)?$}))
31
+ "#{match[1]}/#{match[2]}"
32
+ else
33
+ raise NoRemoteError, "Cannot parse owner/repo from remote URL: #{url}"
34
+ end
35
+ end
36
+ private_class_method :parse_url
37
+ end
38
+ end
@@ -3,9 +3,12 @@
3
3
  require 'open3'
4
4
  require 'shellwords'
5
5
  require_relative 'git_utils'
6
+ require_relative 'command_runner'
6
7
 
7
8
  module Ocak
8
9
  class RereadyProcessor
10
+ include CommandRunner
11
+
9
12
  def initialize(config:, logger:, claude:, issues:, watch: nil)
10
13
  @config = config
11
14
  @logger = logger
@@ -61,17 +64,14 @@ module Ocak
61
64
  end
62
65
 
63
66
  def checkout_pr_branch(branch_name)
64
- _, _, fetch_status = Open3.capture3('git', 'fetch', 'origin', branch_name,
65
- chdir: @config.project_dir)
66
- return false unless fetch_status.success?
67
+ fetch_result = run_git('fetch', 'origin', branch_name, chdir: @config.project_dir)
68
+ return false unless fetch_result.success?
67
69
 
68
- _, _, checkout_status = Open3.capture3('git', 'checkout', branch_name,
69
- chdir: @config.project_dir)
70
- return false unless checkout_status.success?
70
+ checkout_result = run_git('checkout', branch_name, chdir: @config.project_dir)
71
+ return false unless checkout_result.success?
71
72
 
72
- _, _, pull_status = Open3.capture3('git', 'pull', '--rebase', 'origin', branch_name,
73
- chdir: @config.project_dir)
74
- pull_status.success?
73
+ pull_result = run_git('pull', '--rebase', 'origin', branch_name, chdir: @config.project_dir)
74
+ pull_result.success?
75
75
  end
76
76
 
77
77
  def run_feedback_loop(feedback)
@@ -131,8 +131,8 @@ module Ocak
131
131
  )
132
132
  @logger.warn('Proceeding to push without new commit') unless committed
133
133
 
134
- _, _, push_status = Open3.capture3('git', 'push', '--force-with-lease', chdir: @config.project_dir)
135
- push_status.success?
134
+ push_result = run_git('push', '--force-with-lease', chdir: @config.project_dir)
135
+ push_result.success?
136
136
  end
137
137
 
138
138
  def cleanup
@@ -50,6 +50,9 @@ module Ocak
50
50
  path = File.join(dir, "issue-#{issue_number}-#{timestamp}.json")
51
51
  File.write(path, JSON.pretty_generate(to_h(issue_number)))
52
52
  path
53
+ rescue SystemCallError => e
54
+ warn("Failed to save report to #{path}: #{e.message}")
55
+ nil
53
56
  end
54
57
 
55
58
  def to_h(issue_number)
@@ -75,8 +78,8 @@ module Ocak
75
78
 
76
79
  Dir.glob(File.join(dir, 'issue-*.json')).filter_map do |path|
77
80
  JSON.parse(File.read(path), symbolize_names: true)
78
- rescue JSON::ParserError => e
79
- warn("Skipping malformed report #{File.basename(path)}: #{e.message}")
81
+ rescue JSON::ParserError, SystemCallError => e
82
+ warn("Skipping report #{File.basename(path)}: #{e.message}")
80
83
  nil
81
84
  end
82
85
  end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ocak
4
+ # Shutdown orchestration logic — graceful/force shutdown, interrupt/error handling, summary.
5
+ # Extracted from PipelineRunner to reduce file size.
6
+ module ShutdownHandling
7
+ def shutdown!
8
+ count = @active_mutex.synchronize { @shutdown_count += 1 }
9
+
10
+ if count >= 2
11
+ force_shutdown!
12
+ else
13
+ graceful_shutdown!
14
+ end
15
+ end
16
+
17
+ def print_shutdown_summary
18
+ issues = @active_mutex.synchronize { @interrupted_issues.dup }
19
+ return if issues.empty?
20
+
21
+ warn "\nInterrupted issues:"
22
+ issues.each do |issue_number|
23
+ warn " - Issue ##{issue_number}: ocak resume --issue #{issue_number}"
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def graceful_shutdown!
30
+ @shutting_down = true
31
+ warn "\nGraceful shutdown initiated — finishing current agent step(s)..."
32
+ end
33
+
34
+ def force_shutdown!
35
+ @shutting_down = true
36
+ warn "\nForce shutdown — killing active processes..."
37
+ @registry.kill_all
38
+ end
39
+
40
+ def handle_process_error(error, issue_number:, logger:, issues:)
41
+ logger.error("Unexpected #{error.class}: #{error.message}\n#{error.backtrace&.first(5)&.join("\n")}")
42
+ logger.debug("Full backtrace:\n#{error.backtrace&.join("\n")}")
43
+ issues.transition(issue_number, from: @config.label_in_progress, to: @config.label_failed)
44
+ begin
45
+ issues.comment(issue_number, "Unexpected #{error.class}: #{error.message}")
46
+ rescue StandardError => e
47
+ logger&.debug("Comment posting failed: #{e.message}")
48
+ nil
49
+ end
50
+ end
51
+
52
+ def handle_interrupted_issue(issue_number, worktree_path, step_name, logger:, issues:)
53
+ if worktree_path
54
+ GitUtils.commit_changes(chdir: worktree_path,
55
+ message: "wip: pipeline interrupted after step #{step_name} for issue ##{issue_number}",
56
+ logger: logger)
57
+ end
58
+ issues&.transition(issue_number, from: @config.label_in_progress, to: @config.label_ready)
59
+ issues&.comment(issue_number,
60
+ "\u{26A0}\u{FE0F} Pipeline interrupted after #{step_name}. " \
61
+ "Resume with `ocak resume --issue #{issue_number}`.")
62
+ @active_mutex.synchronize { @interrupted_issues << issue_number }
63
+ rescue StandardError => e
64
+ logger.warn("Failed to handle interrupted issue ##{issue_number}: #{e.message}")
65
+ end
66
+ end
67
+ end