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.
- checksums.yaml +4 -4
- data/lib/ocak/agent_generator.rb +2 -2
- data/lib/ocak/commands/hiz.rb +70 -138
- data/lib/ocak/commands/init.rb +7 -6
- data/lib/ocak/commands/resume.rb +4 -4
- data/lib/ocak/config.rb +24 -5
- data/lib/ocak/failure_reporting.rb +16 -0
- data/lib/ocak/git_utils.rb +9 -0
- data/lib/ocak/issue_fetcher.rb +40 -47
- data/lib/ocak/logger.rb +5 -2
- data/lib/ocak/merge_manager.rb +10 -4
- data/lib/ocak/pipeline_executor.rb +119 -63
- data/lib/ocak/pipeline_runner.rb +30 -12
- data/lib/ocak/pipeline_state.rb +11 -2
- data/lib/ocak/planner.rb +1 -2
- data/lib/ocak/process_runner.rb +4 -2
- data/lib/ocak/reready_processor.rb +4 -2
- data/lib/ocak/run_report.rb +2 -0
- data/lib/ocak/step_comments.rb +12 -7
- data/lib/ocak/stream_parser.rb +7 -2
- data/lib/ocak/templates/ocak.yml.erb +1 -0
- data/lib/ocak/verification.rb +37 -2
- data/lib/ocak/worktree_manager.rb +5 -3
- data/lib/ocak.rb +1 -1
- metadata +2 -15
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]
|
|
170
|
-
|
|
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
|
|
data/lib/ocak/merge_manager.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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? ?
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
@
|
|
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(
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
130
|
+
group << [step, idx]
|
|
131
|
+
idx += 1
|
|
132
|
+
end
|
|
133
|
+
group
|
|
134
|
+
end
|
|
100
135
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
357
|
+
active_steps.count do |step|
|
|
302
358
|
step = symbolize(step)
|
|
303
359
|
step[:condition] ||
|
|
304
360
|
(step[:complexity] == 'full' && state[:complexity] == 'simple') ||
|
data/lib/ocak/pipeline_runner.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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,
|
data/lib/ocak/pipeline_state.rb
CHANGED
|
@@ -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)
|
data/lib/ocak/process_runner.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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)
|
data/lib/ocak/run_report.rb
CHANGED
data/lib/ocak/step_comments.rb
CHANGED
|
@@ -2,21 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
module Ocak
|
|
4
4
|
# Shared comment-posting helpers for pipeline steps.
|
|
5
|
-
# Includers
|
|
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
|
-
|
|
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
|
data/lib/ocak/stream_parser.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 %>"
|