Class: Ace::Review::Organisms::FeedbackManager

Inherits:
Object
  • Object
show all
Defined in:
lib/ace/review/organisms/feedback_manager.rb

Overview

Central orchestrator for feedback item lifecycle management.

Coordinates the extraction, storage, querying, and state transitions of feedback items from code reviews. Works with atoms and molecules to provide a unified interface for feedback management.

With the feedback synthesis architecture, multiple review reports are synthesized into unique, deduplicated feedback items with reviewer arrays tracking which models found each issue.

Examples:

Extract and save feedback from review reports

manager = FeedbackManager.new
result = manager.extract_and_save(
  report_paths: ["review-report-gemini.md"],
  base_path: "/project"
)
result[:success] #=> true
result[:items_count] #=> 5

Multi-report synthesis (deduplicated with reviewer arrays)

result = manager.extract_and_save(
  report_paths: [
    "review-report-gemini.md",
    "review-report-claude.md",
    "review-report-gpt.md"
  ],
  base_path: "/project"
)
# Produces ~11 unique findings (not 33 duplicates)

Query feedback items

items = manager.list("/project", status: "pending")
item = manager.find("/project", "abc123")
stats = manager.stats("/project")

State transitions

manager.verify("/project", "abc123", valid: true)
manager.skip("/project", "abc123", reason: "Not applicable")
manager.resolve("/project", "abc123", resolution: "Fixed in commit abc")

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(synthesizer: nil, file_writer: nil, file_reader: nil, directory_manager: nil) ⇒ FeedbackManager

Returns a new instance of FeedbackManager.



52
53
54
55
56
57
58
59
60
61
62
# File 'lib/ace/review/organisms/feedback_manager.rb', line 52

def initialize(
  synthesizer: nil,
  file_writer: nil,
  file_reader: nil,
  directory_manager: nil
)
  @synthesizer = synthesizer || Molecules::FeedbackSynthesizer.new
  @file_writer = file_writer || Molecules::FeedbackFileWriter.new
  @file_reader = file_reader || Molecules::FeedbackFileReader.new
  @directory_manager = directory_manager || Molecules::FeedbackDirectoryManager.new
end

Instance Attribute Details

#directory_manager ⇒ Object (readonly)

Returns the value of attribute directory_manager.



50
51
52
# File 'lib/ace/review/organisms/feedback_manager.rb', line 50

def directory_manager
  @directory_manager
end

#file_reader ⇒ Object (readonly)

Returns the value of attribute file_reader.



50
51
52
# File 'lib/ace/review/organisms/feedback_manager.rb', line 50

def file_reader
  @file_reader
end

#file_writer ⇒ Object (readonly)

Returns the value of attribute file_writer.



50
51
52
# File 'lib/ace/review/organisms/feedback_manager.rb', line 50

def file_writer
  @file_writer
end

#synthesizer ⇒ Object (readonly)

Returns the value of attribute synthesizer.



50
51
52
# File 'lib/ace/review/organisms/feedback_manager.rb', line 50

def synthesizer
  @synthesizer
end

Instance Method Details

#extract_and_save(report_paths:, base_path:, model: nil, session_dir: nil) ⇒ Hash

Extract and synthesize feedback items from review reports and save to disk

For multiple reports, uses FeedbackSynthesizer to produce deduplicated findings with reviewer arrays. For single reports, extracts directly.

Examples:

Single report

result = manager.extract_and_save(
  report_paths: ["session/review-report-gemini.md"],
  base_path: "/project"
)
result #=> { success: true, items_count: 3, paths: [...] }

Multi-report synthesis

result = manager.extract_and_save(
  report_paths: [
    "session/review-report-gemini.md",
    "session/review-report-claude.md"
  ],
  base_path: "/project"
)
# Produces deduplicated findings with reviewers arrays

Parameters:

  • report_paths (Array<String>) —

    Paths to review report files

  • base_path (String) —

    Base project path for feedback directory

  • model (String, nil) (defaults to: nil) —

    Model for synthesis/extraction (optional)

  • session_dir (String, nil) (defaults to: nil) —

    Session directory for LLM output (optional)

Returns:

  • (Hash) —

    Result with :success, :items_count, :paths, :metadata or :error



95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/ace/review/organisms/feedback_manager.rb', line 95

def extract_and_save(report_paths:, base_path:, model: nil, session_dir: nil)
  # Completed findings are immutable; avoid an unnecessary model call
  # on a retry that cannot publish into this session.
  feedback_dir = @directory_manager.feedback_path(base_path)
  if published_findings?(feedback_dir)
    return {success: false, error: "Session already has published findings; start a new review session"}
  end

  # Step 1: Synthesize feedback items from reports (handles deduplication)
  synthesis_result = @synthesizer.synthesize(
    report_paths: report_paths,
    session_dir: session_dir,
    model: model
  )

  unless synthesis_result[:success]
    return {success: false, error: synthesis_result[:error]}
  end

  items = synthesis_result[:items]

  # A completed session's findings are immutable. A new review gets a
  # new session; retry is allowed only when no findings were published.
  lock_path = File.join(base_path, ".feedback_publish.lock")
  File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
    lock.flock(File::LOCK_EX)
    if published_findings?(feedback_dir)
      return {success: false, error: "Session already has published findings; start a new review session"}
    end
    if items.empty?
      result = {success: true, items_count: 0, paths: [], metadata: synthesis_result[:metadata]}

      return result
    end

    # An attempt is private until every finding has been written. A
    # failed model retry cannot leave unaccounted files in the session.
    Dir.mktmpdir(".feedback-attempt-", base_path) do |stage_root|
      stage_dir = @directory_manager.ensure_directory(stage_root)
      staged_paths = []
      errors = []
      items.each do |item|
        write_result = @file_writer.write(item, stage_dir)
        if write_result[:success]
          staged_paths << write_result[:path]
        else
          errors << "Failed to save #{item.id}: #{write_result[:error]}"
        end
      end
      return {success: false, error: errors.join("; "), paths: [], items_count: 0} if errors.any?

      backup = "#{feedback_dir}.previous-#{Process.pid}-#{rand(1_000_000)}"
      File.rename(feedback_dir, backup) if Dir.exist?(feedback_dir)
      begin
        File.rename(stage_dir, feedback_dir)
      rescue SystemCallError
        File.rename(backup, feedback_dir) if Dir.exist?(backup)
        raise
      end
      result = {success: true, items_count: staged_paths.length,
                paths: staged_paths.map { |path| File.join(feedback_dir, File.basename(path)) },
                metadata: synthesis_result[:metadata]}
      FileUtils.rm_rf(backup) if Dir.exist?(backup)
      result
    end
  end
rescue SystemCallError, IOError => e
  {success: false, error: "Failed to publish feedback: #{e.message}", paths: [], items_count: 0}
end

#find(base_path, id) ⇒ Models::FeedbackItem?

Find a specific feedback item by ID

Examples:

item = manager.find("/project", "abc123")
item&.title #=> "Missing error handling"

Parameters:

  • base_path (String) —

    Base project path

  • id (String) —

    Feedback item ID (10-char Base36)

Returns:



209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/ace/review/organisms/feedback_manager.rb', line 209

def find(base_path, id)
  feedback_dir = @directory_manager.feedback_path(base_path)
  return nil unless Dir.exist?(feedback_dir)

  # Find file matching ID pattern
  files = Dir.glob(File.join(feedback_dir, "#{id}-*.s.md"))
  return nil if files.empty?

  # Read and return the item
  result = @file_reader.read(files.first)
  result[:success] ? result[:feedback_item] : nil
end

#list(base_path, status: nil, priority: nil) ⇒ Array<Models::FeedbackItem>

List feedback items with optional filters

Examples:

List all items

items = manager.list("/project")

Filter by status

pending_items = manager.list("/project", status: "pending")

Filter by status and priority

high_pending = manager.list("/project", status: "pending", priority: "high")

Parameters:

  • base_path (String) —

    Base project path

  • status (String, nil) (defaults to: nil) —

    Filter by status (draft, pending, invalid, skip, done)

  • priority (String, nil) (defaults to: nil) —

    Filter by priority (critical, high, medium, low)

Returns:



184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/ace/review/organisms/feedback_manager.rb', line 184

def list(base_path, status: nil, priority: nil)
  feedback_dir = @directory_manager.feedback_path(base_path)
  return [] unless Dir.exist?(feedback_dir)

  items = @file_reader.read_all(feedback_dir)

  # Apply status filter
  items = items.select { |item| item.status == status } if status

  # Apply priority filter (supports exact match "high" or range "high+")
  items = items.select { |item| Atoms::PriorityFilter.matches?(item.priority, priority) } if priority

  # Sort by ID (chronological since IDs are timestamp-based)
  items.sort_by(&:id)
end

#resolve(base_path, id, resolution:) ⇒ Hash

Resolve a feedback item (pending -> done)

Examples:

result = manager.resolve("/project", "abc123", resolution: "Added try-catch in commit abc")

Parameters:

  • base_path (String) —

    Base project path

  • id (String) —

    Feedback item ID

  • resolution (String) —

    Description of how the issue was resolved

Returns:

  • (Hash) —

    Result with :success, :item or :error



329
330
331
332
333
334
335
336
337
# File 'lib/ace/review/organisms/feedback_manager.rb', line 329

def resolve(base_path, id, resolution:)
  transition(
    base_path: base_path,
    id: id,
    to_status: "done",
    allowed_from: ["pending"],
    updates: {resolution: resolution}
  )
end

#skip(base_path, id, reason: nil) ⇒ Hash

Note:

For new code, prefer verify(base_path, id, skip: true, research: reason)

Skip a feedback item (draft/pending -> skip)

Examples:

result = manager.skip("/project", "abc123", reason: "Out of scope for this PR")

Parameters:

  • base_path (String) —

    Base project path

  • id (String) —

    Feedback item ID

  • reason (String, nil) (defaults to: nil) —

    Reason for skipping (optional, aliased to research)

Returns:

  • (Hash) —

    Result with :success, :item or :error



316
317
318
# File 'lib/ace/review/organisms/feedback_manager.rb', line 316

def skip(base_path, id, reason: nil)
  verify(base_path, id, skip: true, research: reason)
end

#stats(base_path) ⇒ Hash

Get statistics about feedback items

Examples:

stats = manager.stats("/project")
stats #=> { draft: 2, pending: 3, invalid: 1, skip: 0, done: 5, total: 11 }

Parameters:

  • base_path (String) —

    Base project path

Returns:

  • (Hash) —

    Statistics with status counts



230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
# File 'lib/ace/review/organisms/feedback_manager.rb', line 230

def stats(base_path)
  # Get items from active directory
  active_items = list(base_path)

  # Get archived items
  archive_dir = @directory_manager.archive_path(base_path)
  archived_items = []
  if Dir.exist?(archive_dir)
    archived_items = @file_reader.read_all(archive_dir)
  end

  all_items = active_items + archived_items

  # Count by status
  counts = Models::FeedbackItem::VALID_STATUSES.map do |status|
    [status.to_sym, all_items.count { |item| item.status == status }]
  end.to_h

  counts[:total] = all_items.length
  counts
end

#verify(base_path, id, valid: nil, skip: nil, research: nil) ⇒ Hash

Verify a feedback item (draft -> pending, draft -> invalid, or draft/pending -> skip)

Examples:

Mark as valid

result = manager.verify("/project", "abc123", valid: true, research: "Confirmed issue")

Mark as invalid

result = manager.verify("/project", "abc123", valid: false, research: "False positive")

Skip

result = manager.verify("/project", "abc123", skip: true, research: "Design decision")

Parameters:

  • base_path (String) —

    Base project path

  • id (String) —

    Feedback item ID

  • valid (Boolean, nil) (defaults to: nil) —

    Whether the feedback is valid (mutually exclusive with skip:)

  • skip (Boolean, nil) (defaults to: nil) —

    Whether to skip the feedback (mutually exclusive with valid:)

  • research (String, nil) (defaults to: nil) —

    Verification research notes (optional)

Returns:

  • (Hash) —

    Result with :success, :item or :error



273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
# File 'lib/ace/review/organisms/feedback_manager.rb', line 273

def verify(base_path, id, valid: nil, skip: nil, research: nil)
  # Validate mutually exclusive options
  if valid.nil? && skip.nil?
    return {success: false, error: "Must specify either valid: or skip:"}
  end
  if !valid.nil? && !skip.nil?
    return {success: false, error: "Cannot specify both valid: and skip:"}
  end

  target_status = if skip
    "skip"
  elsif valid
    "pending"
  else
    "invalid"
  end

  allowed_from = if skip
    %w[draft pending]
  else
    ["draft"]
  end

  transition(
    base_path: base_path,
    id: id,
    to_status: target_status,
    allowed_from: allowed_from,
    updates: research ? {research: research} : {}
  )
end