brainiac-basecamp 0.0.17 → 0.0.18
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/brainiac/plugins/basecamp/cli.rb +238 -8
- data/lib/brainiac/plugins/basecamp/comment_responder.rb +380 -0
- data/lib/brainiac/plugins/basecamp/epic_memory.rb +10 -0
- data/lib/brainiac/plugins/basecamp/hooks.rb +94 -270
- data/lib/brainiac/plugins/basecamp/orchestrator.rb +65 -187
- data/lib/brainiac/plugins/basecamp/review_gate.rb +165 -218
- data/lib/brainiac/plugins/basecamp/session_registry.rb +361 -0
- data/lib/brainiac/plugins/basecamp/task_state.rb +86 -0
- data/lib/brainiac/plugins/basecamp/version.rb +1 -1
- data/lib/brainiac/plugins/basecamp/webhook.rb +4 -6
- data/lib/brainiac/plugins/basecamp.rb +128 -492
- metadata +4 -1
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Brainiac
|
|
8
|
+
module Plugins
|
|
9
|
+
module Basecamp
|
|
10
|
+
# First-class agent session liveness tracking.
|
|
11
|
+
#
|
|
12
|
+
# Tracks active agent sessions by task_id (e.g., "gate-glados-1234",
|
|
13
|
+
# "final-decision-1234", "epic-review-45920028") with PID, dispatch
|
|
14
|
+
# timestamp, and agent name.
|
|
15
|
+
#
|
|
16
|
+
# The registry answers "is an agent actually running for this task?"
|
|
17
|
+
# directly via PID liveness checks — replacing the prior pattern of
|
|
18
|
+
# inferring liveness from Fizzy assignment or elapsed time.
|
|
19
|
+
#
|
|
20
|
+
# Persistence: Sessions are written to disk for crash recovery diagnostics,
|
|
21
|
+
# but the registry is treated as VOLATILE — all sessions are cleared on
|
|
22
|
+
# server restart (a restarted server cannot trust stale PIDs).
|
|
23
|
+
#
|
|
24
|
+
# Usage:
|
|
25
|
+
# SessionRegistry.register_session("gate-glados-1234", pid)
|
|
26
|
+
# SessionRegistry.alive?("gate-glados-1234") # => true/false (checks PID)
|
|
27
|
+
# SessionRegistry.mark_dead("gate-glados-1234")
|
|
28
|
+
# SessionRegistry.sessions_for_epic("epic-45920028")
|
|
29
|
+
#
|
|
30
|
+
module SessionRegistry
|
|
31
|
+
BRAINIAC_DIR = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
|
|
32
|
+
SESSIONS_FILE = File.join(BRAINIAC_DIR, "basecamp_sessions.json")
|
|
33
|
+
IMPLEMENTATION_SESSION_PREFIX = "implementation-"
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
# Register an active agent session.
|
|
37
|
+
#
|
|
38
|
+
# @param task_id [String] Unique key for this session (e.g. "gate-glados-1234")
|
|
39
|
+
# @param pid [Integer] Process ID of the agent
|
|
40
|
+
# @param log_file [String, nil] Path to the agent's log file
|
|
41
|
+
# @param agent_name [String, nil] Name of the agent running
|
|
42
|
+
# @param epic_id [String, nil] Epic ID this session belongs to
|
|
43
|
+
# @param card_number [Integer, nil] Fizzy card number
|
|
44
|
+
# @return [Hash] The registered session record
|
|
45
|
+
def register_session(task_id, pid, log_file: nil, agent_name: nil, epic_id: nil, card_number: nil)
|
|
46
|
+
session = {
|
|
47
|
+
"task_id" => task_id.to_s,
|
|
48
|
+
"pid" => pid.to_i,
|
|
49
|
+
"agent_name" => agent_name,
|
|
50
|
+
"epic_id" => epic_id,
|
|
51
|
+
"card_number" => card_number&.to_i,
|
|
52
|
+
"log_file" => log_file,
|
|
53
|
+
"started_at" => Time.now.iso8601,
|
|
54
|
+
"status" => "active"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
sessions[task_id.to_s] = session
|
|
58
|
+
persist!
|
|
59
|
+
|
|
60
|
+
LOG.info "[Basecamp:SessionRegistry] Registered session: #{task_id} (pid=#{pid}, agent=#{agent_name})" if defined?(LOG)
|
|
61
|
+
|
|
62
|
+
# Also forward to the global register_session if it exists (for waybar/UI)
|
|
63
|
+
if Object.respond_to?(:register_session, true) && !@suppress_global_forward
|
|
64
|
+
begin
|
|
65
|
+
Object.send(:register_session, task_id, pid, log_file: log_file, agent_name: agent_name)
|
|
66
|
+
rescue StandardError
|
|
67
|
+
# Non-critical — waybar integration is optional
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
session
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Check if a session is alive by verifying the PID is still running.
|
|
75
|
+
#
|
|
76
|
+
# @param task_id [String] Session task ID
|
|
77
|
+
# @return [Boolean] true if session exists and its PID is alive
|
|
78
|
+
def alive?(task_id)
|
|
79
|
+
session = sessions[task_id.to_s]
|
|
80
|
+
return false unless session
|
|
81
|
+
return false if session["status"] == "dead"
|
|
82
|
+
|
|
83
|
+
pid = session["pid"]
|
|
84
|
+
return false unless pid&.positive?
|
|
85
|
+
|
|
86
|
+
pid_alive?(pid)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Mark a session as dead (without checking PID).
|
|
90
|
+
# Use when you know the agent has finished or crashed.
|
|
91
|
+
#
|
|
92
|
+
# @param task_id [String] Session task ID
|
|
93
|
+
# @return [Boolean] true if session existed and was marked dead
|
|
94
|
+
def mark_dead(task_id)
|
|
95
|
+
session = sessions[task_id.to_s]
|
|
96
|
+
return false unless session
|
|
97
|
+
|
|
98
|
+
session["status"] = "dead"
|
|
99
|
+
session["ended_at"] = Time.now.iso8601
|
|
100
|
+
persist!
|
|
101
|
+
|
|
102
|
+
LOG.info "[Basecamp:SessionRegistry] Marked dead: #{task_id} (pid=#{session['pid']})" if defined?(LOG)
|
|
103
|
+
true
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Get all sessions belonging to an epic.
|
|
107
|
+
#
|
|
108
|
+
# @param epic_id [String] Epic ID (e.g. "epic-45920028")
|
|
109
|
+
# @return [Array<Hash>] Session records for this epic
|
|
110
|
+
def sessions_for_epic(epic_id)
|
|
111
|
+
sessions.values.select { |s| s["epic_id"] == epic_id.to_s }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Get all active (alive) sessions for an epic.
|
|
115
|
+
#
|
|
116
|
+
# @param epic_id [String] Epic ID
|
|
117
|
+
# @return [Array<Hash>] Active session records
|
|
118
|
+
def active_sessions_for_epic(epic_id)
|
|
119
|
+
sessions_for_epic(epic_id).select { |s| s["status"] == "active" && pid_alive?(s["pid"]) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Get session for a specific task.
|
|
123
|
+
#
|
|
124
|
+
# @param task_id [String] Task ID
|
|
125
|
+
# @return [Hash, nil] Session record or nil
|
|
126
|
+
def find_session(task_id)
|
|
127
|
+
sessions[task_id.to_s]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Check if any session is alive for a given card number.
|
|
131
|
+
# Searches all sessions (gates, final decision, epic review) for this card.
|
|
132
|
+
#
|
|
133
|
+
# @param card_number [Integer] Fizzy card number
|
|
134
|
+
# @return [Boolean]
|
|
135
|
+
def any_alive_for_card?(card_number)
|
|
136
|
+
sessions.values.any? do |s|
|
|
137
|
+
s["card_number"] == card_number.to_i &&
|
|
138
|
+
s["status"] == "active" &&
|
|
139
|
+
pid_alive?(s["pid"])
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Stable task ID for the implementation agent assigned to a Fizzy card.
|
|
144
|
+
# Keep this distinct from gate and final-decision IDs so a live reviewer
|
|
145
|
+
# cannot prevent implementation work from being re-dispatched.
|
|
146
|
+
def implementation_session_id(card_number)
|
|
147
|
+
"#{IMPLEMENTATION_SESSION_PREFIX}#{card_number.to_i}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def implementation_alive?(card_number)
|
|
151
|
+
alive?(implementation_session_id(card_number))
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Mirror Fizzy's real implementation-agent spawn into this registry.
|
|
155
|
+
# Fizzy invokes the global register_session("card-<number>", pid) immediately
|
|
156
|
+
# after run_agent returns; Basecamp installs a small observer around that
|
|
157
|
+
# method so it can attach epic metadata without duplicating Fizzy dispatch.
|
|
158
|
+
def track_global_implementation_session(card_key, pid, log_file: nil, agent_name: nil)
|
|
159
|
+
match = /\Acard-(\d+)\z/.match(card_key.to_s)
|
|
160
|
+
return unless match
|
|
161
|
+
|
|
162
|
+
card_number = match[1].to_i
|
|
163
|
+
epic = Orchestrator.find_epic_for_card(card_number)
|
|
164
|
+
return unless epic
|
|
165
|
+
|
|
166
|
+
register_session(
|
|
167
|
+
implementation_session_id(card_number), pid,
|
|
168
|
+
log_file: log_file,
|
|
169
|
+
agent_name: agent_name,
|
|
170
|
+
epic_id: epic["id"],
|
|
171
|
+
card_number: card_number
|
|
172
|
+
)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Installs the observer once the core session helper is available. Keeping
|
|
176
|
+
# the wrapper here means Basecamp remains compatible with the normal Fizzy
|
|
177
|
+
# assignment flow, while liveness remains owned by SessionRegistry.
|
|
178
|
+
def install_global_registration_hook!
|
|
179
|
+
return if @global_registration_hook_installed
|
|
180
|
+
return unless Object.private_method_defined?(:register_session)
|
|
181
|
+
|
|
182
|
+
observer = Module.new do
|
|
183
|
+
def register_session(card_key, pid, **kwargs)
|
|
184
|
+
result = super
|
|
185
|
+
Brainiac::Plugins::Basecamp::SessionRegistry.track_global_implementation_session(
|
|
186
|
+
card_key,
|
|
187
|
+
pid,
|
|
188
|
+
log_file: kwargs[:log_file],
|
|
189
|
+
agent_name: kwargs[:agent_name]
|
|
190
|
+
)
|
|
191
|
+
result
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
Object.prepend(observer)
|
|
196
|
+
@global_registration_hook_installed = true
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Get all active sessions for a card number.
|
|
200
|
+
#
|
|
201
|
+
# @param card_number [Integer] Fizzy card number
|
|
202
|
+
# @return [Array<Hash>]
|
|
203
|
+
def active_sessions_for_card(card_number)
|
|
204
|
+
sessions.values.select do |s|
|
|
205
|
+
s["card_number"] == card_number.to_i &&
|
|
206
|
+
s["status"] == "active" &&
|
|
207
|
+
pid_alive?(s["pid"])
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Clear all sessions. Called on server restart.
|
|
212
|
+
# Marks all sessions as dead since we can't trust PIDs after restart.
|
|
213
|
+
#
|
|
214
|
+
# @return [Integer] Number of sessions cleared
|
|
215
|
+
def clear_all!
|
|
216
|
+
count = sessions.size
|
|
217
|
+
sessions.each_value do |s|
|
|
218
|
+
s["status"] = "dead"
|
|
219
|
+
s["ended_at"] = Time.now.iso8601
|
|
220
|
+
end
|
|
221
|
+
persist!
|
|
222
|
+
|
|
223
|
+
LOG.info "[Basecamp:SessionRegistry] Cleared #{count} session(s) on startup" if defined?(LOG) && count.positive?
|
|
224
|
+
count
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Sweep dead sessions from memory (cleanup stale entries older than threshold).
|
|
228
|
+
# Keeps dead sessions on disk for diagnostics but removes from active tracking.
|
|
229
|
+
#
|
|
230
|
+
# @param max_age [Integer] Maximum age in seconds for dead sessions (default: 1 hour)
|
|
231
|
+
# @return [Integer] Number of sessions swept
|
|
232
|
+
def sweep!(max_age: 3600)
|
|
233
|
+
now = Time.now
|
|
234
|
+
swept = 0
|
|
235
|
+
|
|
236
|
+
sessions.delete_if do |_task_id, session|
|
|
237
|
+
next false unless session["status"] == "dead"
|
|
238
|
+
|
|
239
|
+
ended_at = session["ended_at"]
|
|
240
|
+
# Orphaned entries (dead with no ended_at) are corrupt/abnormal — remove immediately
|
|
241
|
+
if ended_at.nil?
|
|
242
|
+
swept += 1
|
|
243
|
+
next true
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
age = now - Time.parse(ended_at)
|
|
247
|
+
if age > max_age
|
|
248
|
+
swept += 1
|
|
249
|
+
true
|
|
250
|
+
else
|
|
251
|
+
false
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
persist! if swept.positive?
|
|
256
|
+
swept
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Reap sessions whose PIDs are no longer alive.
|
|
260
|
+
# Call this periodically to detect agents that crashed without notification.
|
|
261
|
+
#
|
|
262
|
+
# @return [Array<String>] Task IDs that were reaped
|
|
263
|
+
def reap_dead!
|
|
264
|
+
reaped = []
|
|
265
|
+
|
|
266
|
+
sessions.each do |task_id, session|
|
|
267
|
+
next unless session["status"] == "active"
|
|
268
|
+
|
|
269
|
+
pid = session["pid"]
|
|
270
|
+
next unless pid&.positive?
|
|
271
|
+
next if pid_alive?(pid)
|
|
272
|
+
|
|
273
|
+
session["status"] = "dead"
|
|
274
|
+
session["ended_at"] = Time.now.iso8601
|
|
275
|
+
session["death_reason"] = "pid_exited"
|
|
276
|
+
reaped << task_id
|
|
277
|
+
LOG.info "[Basecamp:SessionRegistry] Reaped dead session: #{task_id} (pid=#{pid} no longer running)" if defined?(LOG)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
persist! if reaped.any?
|
|
281
|
+
reaped
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Summary of current session state (for API/diagnostics).
|
|
285
|
+
#
|
|
286
|
+
# @return [Hash]
|
|
287
|
+
def status
|
|
288
|
+
active_count = sessions.values.count { |s| s["status"] == "active" && pid_alive?(s["pid"]) }
|
|
289
|
+
dead_count = sessions.values.count { |s| s["status"] == "dead" || (s["status"] == "active" && !pid_alive?(s["pid"])) }
|
|
290
|
+
|
|
291
|
+
{
|
|
292
|
+
"total" => sessions.size,
|
|
293
|
+
"active" => active_count,
|
|
294
|
+
"dead" => dead_count,
|
|
295
|
+
"sessions" => sessions.values.map do |s|
|
|
296
|
+
{
|
|
297
|
+
"task_id" => s["task_id"],
|
|
298
|
+
"pid" => s["pid"],
|
|
299
|
+
"agent_name" => s["agent_name"],
|
|
300
|
+
"epic_id" => s["epic_id"],
|
|
301
|
+
"card_number" => s["card_number"],
|
|
302
|
+
"status" => s["status"] == "active" && pid_alive?(s["pid"]) ? "active" : "dead",
|
|
303
|
+
"started_at" => s["started_at"]
|
|
304
|
+
}
|
|
305
|
+
end
|
|
306
|
+
}
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Suppress forwarding to global register_session (for testing).
|
|
310
|
+
# @api private
|
|
311
|
+
attr_writer :suppress_global_forward
|
|
312
|
+
|
|
313
|
+
# Reset internal state (for testing).
|
|
314
|
+
# @api private
|
|
315
|
+
def reset!
|
|
316
|
+
@sessions = {}
|
|
317
|
+
@suppress_global_forward = false
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
private
|
|
321
|
+
|
|
322
|
+
# In-memory session store.
|
|
323
|
+
def sessions
|
|
324
|
+
@sessions ||= {}
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Check if a PID is alive.
|
|
328
|
+
#
|
|
329
|
+
# @param pid [Integer] Process ID
|
|
330
|
+
# @return [Boolean]
|
|
331
|
+
def pid_alive?(pid)
|
|
332
|
+
return false unless pid&.positive?
|
|
333
|
+
|
|
334
|
+
Process.kill(0, pid)
|
|
335
|
+
true
|
|
336
|
+
rescue Errno::ESRCH
|
|
337
|
+
# No such process
|
|
338
|
+
false
|
|
339
|
+
rescue Errno::EPERM
|
|
340
|
+
# Process exists but we don't have permission to signal it —
|
|
341
|
+
# it's still alive
|
|
342
|
+
true
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Persist current sessions to disk for diagnostics.
|
|
346
|
+
def persist!
|
|
347
|
+
data = {
|
|
348
|
+
"sessions" => sessions,
|
|
349
|
+
"updated_at" => Time.now.iso8601
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
FileUtils.mkdir_p(File.dirname(SESSIONS_FILE))
|
|
353
|
+
File.write(SESSIONS_FILE, JSON.pretty_generate(data))
|
|
354
|
+
rescue StandardError => e
|
|
355
|
+
LOG.warn "[Basecamp:SessionRegistry] Failed to persist sessions: #{e.message}" if defined?(LOG)
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Brainiac
|
|
6
|
+
module Plugins
|
|
7
|
+
module Basecamp
|
|
8
|
+
# The single authority for an epic task's lifecycle. Task hashes remain
|
|
9
|
+
# serializable so existing persisted epics continue to work unchanged.
|
|
10
|
+
module TaskState
|
|
11
|
+
class InvalidTransition < StandardError; end
|
|
12
|
+
|
|
13
|
+
STATES = %w[pending in_flight in_review final_decision complete merge_failed].freeze
|
|
14
|
+
TRANSITIONS = {
|
|
15
|
+
dispatch: { "pending" => "in_flight" },
|
|
16
|
+
submit_for_review: { "pending" => "in_review", "in_flight" => "in_review" },
|
|
17
|
+
request_changes: { "in_review" => "in_flight" },
|
|
18
|
+
approve: { "in_review" => "final_decision" },
|
|
19
|
+
complete: {
|
|
20
|
+
"pending" => "complete",
|
|
21
|
+
"in_flight" => "complete",
|
|
22
|
+
"in_review" => "complete",
|
|
23
|
+
"final_decision" => "complete"
|
|
24
|
+
},
|
|
25
|
+
merge_failed: { "final_decision" => "merge_failed" },
|
|
26
|
+
retry_merge: { "merge_failed" => "final_decision" }
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
def state(task)
|
|
31
|
+
(task["status"] || "pending").to_s
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def in?(task, *states)
|
|
35
|
+
states.flatten.map(&:to_s).include?(state(task))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def transition!(task, event, triggered_by:, at: Time.now, guard: true)
|
|
39
|
+
event = event.to_sym
|
|
40
|
+
from_state = state(task)
|
|
41
|
+
transitions = TRANSITIONS.fetch(event, {})
|
|
42
|
+
# Idempotent no-op: if the task is already in the target state for this
|
|
43
|
+
# event, silently return. This is intentional for recovery paths where a
|
|
44
|
+
# task may be re-processed after a restart — the audit trail already
|
|
45
|
+
# captured the original transition, so logging again would be noise.
|
|
46
|
+
return task if transitions.value?(from_state)
|
|
47
|
+
|
|
48
|
+
to_state = transitions[from_state]
|
|
49
|
+
|
|
50
|
+
raise InvalidTransition, "cannot #{event} task from #{from_state}" unless to_state
|
|
51
|
+
raise InvalidTransition, "guard failed for #{event} from #{from_state}" unless guard
|
|
52
|
+
|
|
53
|
+
migrate!(task, at: at)
|
|
54
|
+
timestamp = at.iso8601
|
|
55
|
+
task["status"] = to_state
|
|
56
|
+
task["transitions"] ||= []
|
|
57
|
+
task["transitions"] << {
|
|
58
|
+
"from_state" => from_state,
|
|
59
|
+
"to_state" => to_state,
|
|
60
|
+
"triggered_by" => triggered_by.to_s,
|
|
61
|
+
"timestamp" => timestamp
|
|
62
|
+
}
|
|
63
|
+
task
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Adds a history entry for state created before this state machine
|
|
67
|
+
# existed, without changing its current status.
|
|
68
|
+
def migrate!(task, triggered_by: "state_machine_migration", at: Time.now)
|
|
69
|
+
return task if task.key?("transitions")
|
|
70
|
+
|
|
71
|
+
current = state(task)
|
|
72
|
+
raise InvalidTransition, "unknown task state #{current}" unless STATES.include?(current)
|
|
73
|
+
|
|
74
|
+
task["transitions"] = [{
|
|
75
|
+
"from_state" => nil,
|
|
76
|
+
"to_state" => current,
|
|
77
|
+
"triggered_by" => triggered_by,
|
|
78
|
+
"timestamp" => at.iso8601
|
|
79
|
+
}]
|
|
80
|
+
task
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -175,12 +175,10 @@ module Brainiac
|
|
|
175
175
|
[200, { status: "noted" }.to_json]
|
|
176
176
|
end
|
|
177
177
|
|
|
178
|
-
# Handle comments on
|
|
179
|
-
#
|
|
180
|
-
def handle_comment(
|
|
181
|
-
|
|
182
|
-
# e.g., "@Galen pause", "@Galen skip", "@Galen reassign to Sherlock"
|
|
183
|
-
[200, { status: "noted" }.to_json]
|
|
178
|
+
# Handle comments on epic todolists or todos within epics.
|
|
179
|
+
# Routes to the mentioned agent, last responder, or epic's default agent.
|
|
180
|
+
def handle_comment(payload, recording)
|
|
181
|
+
CommentResponder.handle(payload, recording)
|
|
184
182
|
end
|
|
185
183
|
end
|
|
186
184
|
end
|