ocak 0.2.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/README.md +246 -70
- data/lib/ocak/agent_generator.rb +4 -2
- data/lib/ocak/claude_runner.rb +7 -6
- data/lib/ocak/commands/clean.rb +67 -6
- data/lib/ocak/commands/design.rb +1 -9
- data/lib/ocak/commands/hiz.rb +137 -80
- data/lib/ocak/commands/init.rb +19 -8
- data/lib/ocak/commands/resume.rb +35 -7
- data/lib/ocak/commands/run.rb +28 -6
- data/lib/ocak/commands/status.rb +120 -6
- data/lib/ocak/config.rb +35 -8
- data/lib/ocak/failure_reporting.rb +16 -0
- data/lib/ocak/git_utils.rb +51 -0
- data/lib/ocak/issue_fetcher.rb +104 -29
- data/lib/ocak/logger.rb +15 -4
- data/lib/ocak/merge_manager.rb +73 -17
- data/lib/ocak/merge_orchestration.rb +102 -0
- data/lib/ocak/pipeline_executor.rb +253 -57
- data/lib/ocak/pipeline_runner.rb +143 -46
- data/lib/ocak/pipeline_state.rb +11 -2
- data/lib/ocak/planner.rb +6 -5
- data/lib/ocak/process_registry.rb +44 -0
- data/lib/ocak/process_runner.rb +14 -6
- data/lib/ocak/reready_processor.rb +176 -0
- data/lib/ocak/run_report.rb +84 -0
- data/lib/ocak/step_comments.rb +28 -0
- data/lib/ocak/stream_parser.rb +17 -3
- data/lib/ocak/templates/agents/documenter.md.erb +39 -9
- data/lib/ocak/templates/agents/implementer.md.erb +42 -0
- data/lib/ocak/templates/agents/planner.md.erb +9 -6
- data/lib/ocak/templates/gitignore_additions.txt +2 -0
- data/lib/ocak/templates/ocak.yml.erb +7 -2
- data/lib/ocak/verification.rb +37 -2
- data/lib/ocak/worktree_manager.rb +10 -4
- data/lib/ocak.rb +1 -1
- metadata +8 -15
data/lib/ocak/pipeline_runner.rb
CHANGED
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'failure_reporting'
|
|
4
|
+
require_relative 'merge_orchestration'
|
|
3
5
|
require_relative 'pipeline_executor'
|
|
6
|
+
require_relative 'process_registry'
|
|
7
|
+
require_relative 'git_utils'
|
|
8
|
+
require_relative 'reready_processor'
|
|
4
9
|
|
|
5
10
|
module Ocak
|
|
6
11
|
class PipelineRunner
|
|
12
|
+
include FailureReporting
|
|
13
|
+
include MergeOrchestration
|
|
14
|
+
|
|
15
|
+
attr_reader :registry
|
|
16
|
+
|
|
7
17
|
def initialize(config:, options: {})
|
|
8
18
|
@config = config
|
|
9
19
|
@options = options
|
|
10
20
|
@watch_formatter = options[:watch] ? WatchFormatter.new : nil
|
|
11
21
|
@shutting_down = false
|
|
22
|
+
@shutdown_count = 0
|
|
12
23
|
@active_issues = []
|
|
24
|
+
@interrupted_issues = []
|
|
13
25
|
@active_mutex = Mutex.new
|
|
14
|
-
@
|
|
26
|
+
@registry = ProcessRegistry.new
|
|
27
|
+
@executor = PipelineExecutor.new(config: config, shutdown_check: -> { @shutting_down })
|
|
15
28
|
end
|
|
16
29
|
|
|
17
30
|
def run
|
|
@@ -24,18 +37,26 @@ module Ocak
|
|
|
24
37
|
end
|
|
25
38
|
|
|
26
39
|
def shutdown!
|
|
27
|
-
|
|
28
|
-
logger = build_logger
|
|
29
|
-
logger.info('Graceful shutdown initiated...')
|
|
40
|
+
count = @active_mutex.synchronize { @shutdown_count += 1 }
|
|
30
41
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
42
|
+
if count >= 2
|
|
43
|
+
force_shutdown!
|
|
44
|
+
else
|
|
45
|
+
graceful_shutdown!
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def shutting_down?
|
|
50
|
+
@shutting_down
|
|
51
|
+
end
|
|
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}"
|
|
39
60
|
end
|
|
40
61
|
end
|
|
41
62
|
|
|
@@ -45,6 +66,8 @@ module Ocak
|
|
|
45
66
|
logger = build_logger(issue_number: issue_number)
|
|
46
67
|
claude = build_claude(logger)
|
|
47
68
|
issues = IssueFetcher.new(config: @config)
|
|
69
|
+
ensure_labels(issues, logger)
|
|
70
|
+
@executor.issues = issues
|
|
48
71
|
logger.info("Running single issue mode for ##{issue_number}")
|
|
49
72
|
|
|
50
73
|
if @options[:dry_run]
|
|
@@ -53,17 +76,15 @@ module Ocak
|
|
|
53
76
|
end
|
|
54
77
|
|
|
55
78
|
issues.transition(issue_number, from: @config.label_ready, to: @config.label_in_progress)
|
|
56
|
-
|
|
79
|
+
complexity = @options[:fast] ? 'simple' : 'full'
|
|
80
|
+
result = run_pipeline(issue_number, logger: logger, claude: claude, complexity: complexity)
|
|
57
81
|
|
|
58
|
-
if result[:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
logger.info("Issue ##{issue_number} completed successfully")
|
|
82
|
+
if result[:interrupted]
|
|
83
|
+
handle_interrupted_issue(issue_number, nil, result[:phase], logger: logger, issues: issues)
|
|
84
|
+
elsif result[:success]
|
|
85
|
+
handle_single_success(issue_number, result, logger: logger, claude: claude, issues: issues)
|
|
63
86
|
else
|
|
64
|
-
|
|
65
|
-
issues.comment(issue_number,
|
|
66
|
-
"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)
|
|
67
88
|
logger.error("Issue ##{issue_number} failed at phase: #{result[:phase]}")
|
|
68
89
|
end
|
|
69
90
|
end
|
|
@@ -71,11 +92,15 @@ module Ocak
|
|
|
71
92
|
def run_loop
|
|
72
93
|
logger = build_logger
|
|
73
94
|
issues = IssueFetcher.new(config: @config, logger: logger)
|
|
95
|
+
ensure_labels(issues, logger)
|
|
96
|
+
@executor.issues = issues
|
|
74
97
|
cleanup_stale_worktrees(logger)
|
|
75
98
|
|
|
76
99
|
loop do
|
|
77
100
|
break if @shutting_down
|
|
78
101
|
|
|
102
|
+
process_reready_prs(logger: logger, issues: issues) if @config.manual_review
|
|
103
|
+
|
|
79
104
|
logger.info("Checking for #{@config.label_ready} issues...")
|
|
80
105
|
ready = issues.fetch_ready
|
|
81
106
|
|
|
@@ -89,7 +114,11 @@ module Ocak
|
|
|
89
114
|
break if @options[:once]
|
|
90
115
|
|
|
91
116
|
logger.info("Sleeping #{@config.poll_interval}s...")
|
|
92
|
-
|
|
117
|
+
@config.poll_interval.times do
|
|
118
|
+
break if @shutting_down
|
|
119
|
+
|
|
120
|
+
sleep 1
|
|
121
|
+
end
|
|
93
122
|
end
|
|
94
123
|
end
|
|
95
124
|
|
|
@@ -116,33 +145,33 @@ module Ocak
|
|
|
116
145
|
end
|
|
117
146
|
|
|
118
147
|
def run_batch(batch_issues, logger:, issues:)
|
|
119
|
-
worktrees = WorktreeManager.new(config: @config)
|
|
148
|
+
worktrees = WorktreeManager.new(config: @config, logger: logger)
|
|
120
149
|
|
|
121
150
|
threads = batch_issues.map do |issue|
|
|
122
151
|
Thread.new { process_one_issue(issue, worktrees: worktrees, issues: issues) }
|
|
123
152
|
end
|
|
124
153
|
results = threads.map(&:value)
|
|
125
154
|
|
|
126
|
-
|
|
155
|
+
unless @shutting_down
|
|
127
156
|
merger = MergeManager.new(
|
|
128
|
-
config: @config, claude: build_claude(logger), logger: logger, watch: @watch_formatter
|
|
157
|
+
config: @config, claude: build_claude(logger), logger: logger, issues: issues, watch: @watch_formatter
|
|
129
158
|
)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
logger.info("Issue ##{result[:issue_number]} merged successfully")
|
|
133
|
-
else
|
|
134
|
-
issues.transition(result[:issue_number], from: @config.label_in_progress, to: @config.label_failed)
|
|
135
|
-
logger.error("Issue ##{result[:issue_number]} merge failed")
|
|
159
|
+
results.select { |r| r[:success] }.each do |result|
|
|
160
|
+
merge_completed_issue(result, merger: merger, issues: issues, logger: logger)
|
|
136
161
|
end
|
|
137
162
|
end
|
|
138
163
|
|
|
139
164
|
results.each do |result|
|
|
140
165
|
next unless result[:worktree]
|
|
166
|
+
next if result[:interrupted]
|
|
141
167
|
|
|
142
168
|
worktrees.remove(result[:worktree])
|
|
143
169
|
rescue StandardError => e
|
|
144
170
|
logger.warn("Failed to clean worktree for ##{result[:issue_number]}: #{e.message}")
|
|
145
171
|
end
|
|
172
|
+
|
|
173
|
+
programming_error = results.find { |r| r[:programming_error] }&.dig(:programming_error)
|
|
174
|
+
raise programming_error if programming_error
|
|
146
175
|
end
|
|
147
176
|
|
|
148
177
|
def process_one_issue(issue, worktrees:, issues:)
|
|
@@ -151,48 +180,116 @@ module Ocak
|
|
|
151
180
|
claude = build_claude(logger)
|
|
152
181
|
worktree = nil
|
|
153
182
|
|
|
154
|
-
@active_mutex.synchronize
|
|
183
|
+
@active_mutex.synchronize do
|
|
184
|
+
@active_issues << issue_number
|
|
185
|
+
end
|
|
155
186
|
issues.transition(issue_number, from: @config.label_ready, to: @config.label_in_progress)
|
|
156
187
|
worktree = worktrees.create(issue_number, setup_command: @config.setup_command)
|
|
157
188
|
logger.info("Created worktree at #{worktree.path} (branch: #{worktree.branch})")
|
|
158
189
|
|
|
190
|
+
complexity = @options[:fast] ? 'simple' : issue.fetch('complexity', 'full')
|
|
159
191
|
result = run_pipeline(issue_number, logger: logger, claude: claude, chdir: worktree.path,
|
|
160
|
-
complexity:
|
|
192
|
+
complexity: complexity)
|
|
161
193
|
|
|
162
|
-
build_issue_result(result, issue_number: issue_number, worktree: worktree, issues: issues
|
|
194
|
+
build_issue_result(result, issue_number: issue_number, worktree: worktree, issues: issues,
|
|
195
|
+
logger: logger)
|
|
163
196
|
rescue StandardError => e
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
167
202
|
ensure
|
|
168
203
|
@active_mutex.synchronize { @active_issues.delete(issue_number) }
|
|
169
204
|
end
|
|
170
205
|
|
|
171
|
-
def build_issue_result(result, issue_number:, worktree:, issues:)
|
|
172
|
-
if result[:
|
|
173
|
-
|
|
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] }
|
|
174
214
|
else
|
|
175
|
-
|
|
176
|
-
issues.comment(issue_number,
|
|
177
|
-
"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)
|
|
178
216
|
{ issue_number: issue_number, success: false, worktree: worktree }
|
|
179
217
|
end
|
|
180
218
|
end
|
|
181
219
|
|
|
220
|
+
def process_reready_prs(logger:, issues:)
|
|
221
|
+
reready = issues.fetch_reready_prs
|
|
222
|
+
return if reready.empty?
|
|
223
|
+
|
|
224
|
+
logger.info("Found #{reready.size} reready PR(s)")
|
|
225
|
+
processor = RereadyProcessor.new(config: @config, logger: logger,
|
|
226
|
+
claude: build_claude(logger), issues: issues,
|
|
227
|
+
watch: @watch_formatter)
|
|
228
|
+
reready.each do |pr|
|
|
229
|
+
break if @shutting_down
|
|
230
|
+
|
|
231
|
+
processor.process(pr)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
182
235
|
def cleanup_stale_worktrees(logger)
|
|
183
|
-
worktrees = WorktreeManager.new(config: @config)
|
|
236
|
+
worktrees = WorktreeManager.new(config: @config, logger: logger)
|
|
184
237
|
removed = worktrees.clean_stale
|
|
185
238
|
removed.each { |path| logger.info("Cleaned stale worktree: #{path}") }
|
|
186
239
|
rescue StandardError => e
|
|
187
240
|
logger.warn("Stale worktree cleanup failed: #{e.message}")
|
|
188
241
|
end
|
|
189
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
|
+
|
|
190
249
|
def build_logger(issue_number: nil)
|
|
191
|
-
PipelineLogger.new(log_dir: File.join(@config.project_dir, @config.log_dir),
|
|
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))
|
|
192
252
|
end
|
|
193
253
|
|
|
194
254
|
def build_claude(logger)
|
|
195
|
-
ClaudeRunner.new(config: @config, logger: logger, watch: @watch_formatter)
|
|
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}")
|
|
196
293
|
end
|
|
197
294
|
end
|
|
198
295
|
end
|
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,14 +11,12 @@ 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
|
-
'
|
|
15
|
-
'merge' => 'Create a PR, merge it, and close issue #%<issue>s',
|
|
16
|
-
'create_pr' => 'Create a PR, merge it, and close issue #%<issue>s'
|
|
14
|
+
'merge' => 'Create a PR, merge it, and close issue #%<issue>s'
|
|
17
15
|
}.freeze
|
|
18
16
|
|
|
19
17
|
def build_step_prompt(role, issue_number, review_output)
|
|
20
18
|
if role == 'fix'
|
|
21
|
-
"Fix these review findings for issue ##{issue_number}:\n\n#{review_output}"
|
|
19
|
+
"Fix these review findings for issue ##{issue_number}:\n\n<review_output>\n#{review_output}\n</review_output>"
|
|
22
20
|
elsif STEP_PROMPTS.key?(role)
|
|
23
21
|
format(STEP_PROMPTS[role], issue: issue_number)
|
|
24
22
|
else
|
|
@@ -47,7 +45,10 @@ module Ocak
|
|
|
47
45
|
json_match = output.match(/\{[\s\S]*"batches"[\s\S]*\}/)
|
|
48
46
|
if json_match
|
|
49
47
|
parsed = JSON.parse(json_match[0])
|
|
50
|
-
parsed['batches']
|
|
48
|
+
batches = parsed['batches']
|
|
49
|
+
return sequential_batches(issues) unless batches.is_a?(Array)
|
|
50
|
+
|
|
51
|
+
batches
|
|
51
52
|
else
|
|
52
53
|
logger.warn('Could not parse planner output, falling back to sequential')
|
|
53
54
|
sequential_batches(issues)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ocak
|
|
4
|
+
class ProcessRegistry
|
|
5
|
+
KILL_WAIT = 2
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@pids = Set.new
|
|
9
|
+
@mutex = Mutex.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def register(pid)
|
|
13
|
+
@mutex.synchronize { @pids.add(pid) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def unregister(pid)
|
|
17
|
+
@mutex.synchronize { @pids.delete(pid) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def pids
|
|
21
|
+
@mutex.synchronize { @pids.dup }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def kill_all(signal: :TERM, wait: KILL_WAIT)
|
|
25
|
+
snapshot = pids
|
|
26
|
+
return if snapshot.empty?
|
|
27
|
+
|
|
28
|
+
snapshot.each do |pid|
|
|
29
|
+
Process.kill(signal, pid)
|
|
30
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
sleep wait
|
|
35
|
+
|
|
36
|
+
# SIGKILL any survivors; ESRCH means already exited, which is fine
|
|
37
|
+
snapshot.each do |pid|
|
|
38
|
+
Process.kill(:KILL, pid)
|
|
39
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
data/lib/ocak/process_runner.rb
CHANGED
|
@@ -5,15 +5,22 @@ 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
|
+
|
|
10
|
+
FailedStatus = Struct.new(:success?) do
|
|
11
|
+
def self.instance = new(false)
|
|
12
|
+
end
|
|
13
|
+
|
|
8
14
|
module_function
|
|
9
15
|
|
|
10
|
-
def run(cmd, chdir:, timeout: nil, on_line: nil)
|
|
16
|
+
def run(cmd, chdir:, timeout: nil, on_line: nil, registry: nil)
|
|
11
17
|
stdout = +''
|
|
12
18
|
stderr = +''
|
|
13
19
|
line_buf = +''
|
|
14
20
|
|
|
15
21
|
Open3.popen3(*cmd, chdir: chdir) do |stdin, out, err, wait_thr|
|
|
16
22
|
stdin.close
|
|
23
|
+
registry&.register(wait_thr.pid)
|
|
17
24
|
ctx = {
|
|
18
25
|
stdout: +'', stderr: +'', line_buf: +'',
|
|
19
26
|
deadline: timeout ? Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout : nil,
|
|
@@ -23,9 +30,11 @@ module Ocak
|
|
|
23
30
|
stdout, stderr, line_buf = read_streams(out, err, ctx)
|
|
24
31
|
on_line&.call(line_buf.chomp) unless line_buf.empty?
|
|
25
32
|
[stdout, stderr, wait_thr.value]
|
|
33
|
+
ensure
|
|
34
|
+
registry&.unregister(wait_thr.pid)
|
|
26
35
|
end
|
|
27
36
|
rescue Errno::ENOENT => e
|
|
28
|
-
['', e.message,
|
|
37
|
+
['', e.message, FailedStatus.instance]
|
|
29
38
|
end
|
|
30
39
|
|
|
31
40
|
def read_streams(out, err, ctx)
|
|
@@ -47,9 +56,9 @@ module Ocak
|
|
|
47
56
|
|
|
48
57
|
def kill_process(pid)
|
|
49
58
|
Process.kill('TERM', pid)
|
|
50
|
-
sleep
|
|
59
|
+
sleep KILL_GRACE_PERIOD
|
|
51
60
|
Process.kill('KILL', pid)
|
|
52
|
-
rescue Errno::ESRCH => e
|
|
61
|
+
rescue Errno::ESRCH, Errno::EPERM => e
|
|
53
62
|
warn("Process already exited during kill: #{e.message}")
|
|
54
63
|
nil
|
|
55
64
|
end
|
|
@@ -66,8 +75,7 @@ module Ocak
|
|
|
66
75
|
else
|
|
67
76
|
ctx[:stderr] << chunk
|
|
68
77
|
end
|
|
69
|
-
rescue EOFError
|
|
70
|
-
warn("Stream EOF for subprocess IO: #{e.message}")
|
|
78
|
+
rescue EOFError
|
|
71
79
|
readers.delete(io)
|
|
72
80
|
end
|
|
73
81
|
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'shellwords'
|
|
5
|
+
require_relative 'git_utils'
|
|
6
|
+
|
|
7
|
+
module Ocak
|
|
8
|
+
class RereadyProcessor
|
|
9
|
+
def initialize(config:, logger:, claude:, issues:, watch: nil)
|
|
10
|
+
@config = config
|
|
11
|
+
@logger = logger
|
|
12
|
+
@claude = claude
|
|
13
|
+
@issues = issues
|
|
14
|
+
@watch = watch
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Main entry point. Returns true on success.
|
|
18
|
+
def process(pull_request)
|
|
19
|
+
pr_number = pull_request['number']
|
|
20
|
+
issue_number = @issues.extract_issue_number_from_pr(pull_request)
|
|
21
|
+
unless issue_number
|
|
22
|
+
@logger.warn("PR ##{pr_number}: could not extract issue number from body")
|
|
23
|
+
return false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
feedback = gather_feedback(pr_number, issue_number)
|
|
27
|
+
return false unless feedback
|
|
28
|
+
|
|
29
|
+
branch_name = pull_request['headRefName']
|
|
30
|
+
unless GitUtils.safe_branch_name?(branch_name)
|
|
31
|
+
@logger.error("PR ##{pr_number}: unsafe branch name '#{branch_name}'")
|
|
32
|
+
return false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
unless checkout_pr_branch(branch_name)
|
|
36
|
+
@logger.error("PR ##{pr_number}: failed to checkout branch #{branch_name}")
|
|
37
|
+
cleanup
|
|
38
|
+
return false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
success = run_feedback_loop(feedback)
|
|
42
|
+
handle_result(pr_number, success)
|
|
43
|
+
ensure
|
|
44
|
+
cleanup
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def gather_feedback(pr_number, issue_number)
|
|
50
|
+
comments_data = @issues.fetch_pr_comments(pr_number)
|
|
51
|
+
issue_data = fetch_issue(issue_number)
|
|
52
|
+
return nil unless issue_data
|
|
53
|
+
|
|
54
|
+
{ pr_number: pr_number, issue_number: issue_number,
|
|
55
|
+
comments: comments_data[:comments], reviews: comments_data[:reviews],
|
|
56
|
+
issue_title: issue_data['title'], issue_body: issue_data['body'] }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def fetch_issue(issue_number)
|
|
60
|
+
@issues.view(issue_number, fields: 'title,body')
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
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
|
+
|
|
68
|
+
_, _, checkout_status = Open3.capture3('git', 'checkout', branch_name,
|
|
69
|
+
chdir: @config.project_dir)
|
|
70
|
+
return false unless checkout_status.success?
|
|
71
|
+
|
|
72
|
+
_, _, pull_status = Open3.capture3('git', 'pull', '--rebase', 'origin', branch_name,
|
|
73
|
+
chdir: @config.project_dir)
|
|
74
|
+
pull_status.success?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def run_feedback_loop(feedback)
|
|
78
|
+
prompt = build_feedback_prompt(feedback)
|
|
79
|
+
result = @claude.run_agent('implementer', prompt, chdir: @config.project_dir)
|
|
80
|
+
return false unless result.success?
|
|
81
|
+
|
|
82
|
+
verified = run_verification
|
|
83
|
+
return true if verified
|
|
84
|
+
|
|
85
|
+
# One retry
|
|
86
|
+
retry_result = @claude.run_agent('implementer', "Fix the failing tests and lint errors.\n#{prompt}",
|
|
87
|
+
chdir: @config.project_dir)
|
|
88
|
+
return false unless retry_result.success?
|
|
89
|
+
|
|
90
|
+
run_verification
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def run_verification
|
|
94
|
+
test_ok = run_optional_cmd(@config.test_command)
|
|
95
|
+
lint_ok = run_optional_cmd(@config.lint_check_command)
|
|
96
|
+
test_ok && lint_ok
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def run_optional_cmd(cmd)
|
|
100
|
+
return true if cmd.nil? || cmd.empty?
|
|
101
|
+
|
|
102
|
+
_, _, status = Open3.capture3(*Shellwords.shellsplit(cmd), chdir: @config.project_dir)
|
|
103
|
+
status.success?
|
|
104
|
+
rescue ArgumentError => e
|
|
105
|
+
@logger&.warn("Invalid shell command in config: #{cmd.inspect} (#{e.message})")
|
|
106
|
+
false
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def handle_result(pr_number, success)
|
|
110
|
+
if success
|
|
111
|
+
push_ok = push_updates
|
|
112
|
+
@issues.pr_transition(pr_number, remove_label: @config.label_reready)
|
|
113
|
+
if push_ok
|
|
114
|
+
@issues.pr_comment(pr_number, 'Feedback addressed. Please re-review.')
|
|
115
|
+
else
|
|
116
|
+
@issues.pr_comment(pr_number, 'Failed to push feedback changes. Please check logs.')
|
|
117
|
+
end
|
|
118
|
+
push_ok
|
|
119
|
+
else
|
|
120
|
+
@issues.pr_transition(pr_number, remove_label: @config.label_reready)
|
|
121
|
+
@issues.pr_comment(pr_number, 'Failed to address feedback automatically. Please check logs.')
|
|
122
|
+
false
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def push_updates
|
|
127
|
+
committed = GitUtils.commit_changes(
|
|
128
|
+
chdir: @config.project_dir,
|
|
129
|
+
message: 'fix: address review feedback',
|
|
130
|
+
logger: @logger
|
|
131
|
+
)
|
|
132
|
+
@logger.warn('Proceeding to push without new commit') unless committed
|
|
133
|
+
|
|
134
|
+
_, _, push_status = Open3.capture3('git', 'push', '--force-with-lease', chdir: @config.project_dir)
|
|
135
|
+
push_status.success?
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def cleanup
|
|
139
|
+
GitUtils.checkout_main(chdir: @config.project_dir, logger: @logger)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def build_feedback_prompt(feedback)
|
|
143
|
+
reviews_text = Array(feedback[:reviews]).map do |r|
|
|
144
|
+
"- #{r.dig('author', 'login')} [#{r['state']}]: #{r['body']}"
|
|
145
|
+
end.join("\n")
|
|
146
|
+
|
|
147
|
+
comments_text = Array(feedback[:comments]).map do |c|
|
|
148
|
+
"- #{c.dig('author', 'login')}: #{c['body']}"
|
|
149
|
+
end.join("\n")
|
|
150
|
+
|
|
151
|
+
<<~PROMPT
|
|
152
|
+
Address the review feedback on PR ##{feedback[:pr_number]} for issue ##{feedback[:issue_number]}.
|
|
153
|
+
|
|
154
|
+
## Original Issue: #{feedback[:issue_title]}
|
|
155
|
+
<issue_body>
|
|
156
|
+
#{feedback[:issue_body]}
|
|
157
|
+
</issue_body>
|
|
158
|
+
|
|
159
|
+
## Review Comments
|
|
160
|
+
<review_comments>
|
|
161
|
+
#{reviews_text.empty? ? '(none)' : reviews_text}
|
|
162
|
+
</review_comments>
|
|
163
|
+
|
|
164
|
+
## PR Comments
|
|
165
|
+
<pr_comments>
|
|
166
|
+
#{comments_text.empty? ? '(none)' : comments_text}
|
|
167
|
+
</pr_comments>
|
|
168
|
+
|
|
169
|
+
## Instructions
|
|
170
|
+
Read the PR diff with `git diff main` to understand current changes.
|
|
171
|
+
Address each piece of feedback. Do NOT revert changes unless explicitly requested.
|
|
172
|
+
Run tests and lint after making changes.
|
|
173
|
+
PROMPT
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|