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.
- checksums.yaml +4 -4
- data/README.md +27 -0
- data/lib/ocak/agent_generator.rb +2 -1
- data/lib/ocak/batch_processing.rb +102 -0
- data/lib/ocak/claude_runner.rb +12 -8
- data/lib/ocak/cli.rb +13 -0
- data/lib/ocak/command_runner.rb +39 -0
- data/lib/ocak/commands/hiz.rb +8 -7
- data/lib/ocak/commands/init.rb +5 -0
- data/lib/ocak/commands/issue/close.rb +37 -0
- data/lib/ocak/commands/issue/create.rb +59 -0
- data/lib/ocak/commands/issue/edit.rb +31 -0
- data/lib/ocak/commands/issue/list.rb +43 -0
- data/lib/ocak/commands/issue/view.rb +58 -0
- data/lib/ocak/commands/resume.rb +4 -3
- data/lib/ocak/commands/status.rb +20 -0
- data/lib/ocak/config.rb +4 -0
- data/lib/ocak/failure_reporting.rb +3 -2
- data/lib/ocak/git_utils.rb +18 -11
- data/lib/ocak/instance_builders.rb +50 -0
- data/lib/ocak/issue_backend.rb +31 -0
- data/lib/ocak/local_issue_fetcher.rb +165 -0
- data/lib/ocak/local_merge_manager.rb +104 -0
- data/lib/ocak/merge_manager.rb +31 -32
- data/lib/ocak/merge_orchestration.rb +8 -2
- data/lib/ocak/parallel_execution.rb +36 -0
- data/lib/ocak/pipeline_executor.rb +9 -183
- data/lib/ocak/pipeline_runner.rb +15 -180
- data/lib/ocak/planner.rb +1 -1
- data/lib/ocak/project_key.rb +38 -0
- data/lib/ocak/reready_processor.rb +11 -11
- data/lib/ocak/run_report.rb +5 -2
- data/lib/ocak/shutdown_handling.rb +67 -0
- data/lib/ocak/state_management.rb +104 -0
- data/lib/ocak/step_execution.rb +66 -0
- data/lib/ocak/templates/agents/auditor.md.erb +38 -9
- data/lib/ocak/templates/agents/implementer.md.erb +32 -8
- data/lib/ocak/templates/agents/merger.md.erb +12 -5
- data/lib/ocak/templates/agents/pipeline.md.erb +4 -0
- data/lib/ocak/templates/agents/reviewer.md.erb +2 -2
- data/lib/ocak/templates/agents/security_reviewer.md.erb +11 -0
- data/lib/ocak/templates/ocak.yml.erb +15 -0
- data/lib/ocak/verification.rb +6 -1
- data/lib/ocak/worktree_manager.rb +2 -0
- data/lib/ocak.rb +1 -1
- 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
|
-
|
|
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
|
data/lib/ocak/pipeline_runner.rb
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
data/lib/ocak/run_report.rb
CHANGED
|
@@ -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
|
|
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
|