collavre 0.21.0 → 0.22.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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/comments_popup.css +51 -3
  3. data/app/assets/stylesheets/collavre/popup.css +148 -0
  4. data/app/channels/collavre/agent_channel.rb +205 -0
  5. data/app/controllers/collavre/admin/integrations_controller.rb +16 -5
  6. data/app/controllers/collavre/api/v1/agents_controller.rb +777 -0
  7. data/app/controllers/collavre/api/v1/base_controller.rb +46 -0
  8. data/app/controllers/collavre/attachments_controller.rb +30 -2
  9. data/app/controllers/collavre/comments_controller.rb +1 -1
  10. data/app/controllers/collavre/creatives/attachments_controller.rb +79 -0
  11. data/app/controllers/collavre/creatives_controller.rb +91 -1
  12. data/app/controllers/collavre/tasks_controller.rb +12 -4
  13. data/app/controllers/collavre/topics_controller.rb +15 -0
  14. data/app/controllers/concerns/collavre/comments/approval_actions.rb +57 -14
  15. data/app/javascript/components/InlineLexicalEditor.jsx +42 -59
  16. data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +3 -0
  17. data/app/javascript/components/plugins/image_upload_plugin.jsx +12 -0
  18. data/app/javascript/controllers/__tests__/link_creative_controller.test.js +447 -0
  19. data/app/javascript/controllers/comment_controller.js +6 -1
  20. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +108 -0
  21. data/app/javascript/controllers/comments/list_controller.js +17 -2
  22. data/app/javascript/controllers/comments/presence_controller.js +56 -5
  23. data/app/javascript/controllers/link_creative_controller.js +451 -29
  24. data/app/javascript/lib/api/creatives.js +13 -0
  25. data/app/javascript/lib/lexical/__tests__/color_import.test.js +318 -0
  26. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +259 -0
  27. data/app/javascript/lib/lexical/color_import.js +186 -0
  28. data/app/javascript/lib/lexical/minimize_html.js +182 -0
  29. data/app/javascript/lib/lexical/video_node.jsx +96 -0
  30. data/app/jobs/collavre/ai_agent_job.rb +89 -3
  31. data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +134 -0
  32. data/app/jobs/collavre/claude_channel_presence_job.rb +91 -0
  33. data/app/models/collavre/agent_subscription.rb +52 -0
  34. data/app/models/collavre/comment/claude_channel_permission.rb +145 -0
  35. data/app/models/collavre/comment.rb +70 -5
  36. data/app/models/collavre/creative/describable.rb +139 -2
  37. data/app/models/collavre/creative_share.rb +1 -0
  38. data/app/models/collavre/task.rb +34 -5
  39. data/app/models/collavre/topic.rb +5 -0
  40. data/app/models/collavre/user.rb +4 -0
  41. data/app/services/collavre/agent_session_abort.rb +28 -0
  42. data/app/services/collavre/ai_agent/claude_channel_adapter.rb +79 -0
  43. data/app/services/collavre/ai_agent/response_finalizer.rb +4 -2
  44. data/app/services/collavre/ai_agent_service.rb +68 -49
  45. data/app/services/collavre/attachment_backfill.rb +26 -0
  46. data/app/services/collavre/creatives/breadcrumb_resolver.rb +91 -0
  47. data/app/services/collavre/creatives/filter_pipeline.rb +26 -42
  48. data/app/services/collavre/creatives/filters/search_filter.rb +12 -2
  49. data/app/services/collavre/creatives/index_query.rb +110 -8
  50. data/app/services/collavre/creatives/permission_filter.rb +50 -0
  51. data/app/services/collavre/creatives/reveal_path_resolver.rb +118 -0
  52. data/app/services/collavre/crons/recurring_task_arguments.rb +28 -0
  53. data/app/services/collavre/orchestration/stuck_detector.rb +22 -2
  54. data/app/services/collavre/tools/creative_attach_files_service.rb +29 -63
  55. data/app/services/collavre/tools/creative_remove_attachment_service.rb +7 -5
  56. data/app/services/collavre/tools/cron_list_service.rb +1 -14
  57. data/app/services/collavre/topics/orphaned_cron_notifier.rb +68 -0
  58. data/app/views/collavre/admin/integrations/_category.html.erb +1 -1
  59. data/app/views/collavre/admin/integrations/_setting_row.html.erb +27 -11
  60. data/app/views/collavre/comments/_comment.html.erb +10 -1
  61. data/app/views/collavre/comments/_comments_popup.html.erb +4 -2
  62. data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -2
  63. data/config/locales/channels.en.yml +2 -0
  64. data/config/locales/channels.ko.yml +2 -0
  65. data/config/locales/claude_channel.en.yml +16 -0
  66. data/config/locales/claude_channel.ko.yml +16 -0
  67. data/config/locales/comments.en.yml +3 -0
  68. data/config/locales/comments.ko.yml +3 -0
  69. data/config/locales/creatives.en.yml +2 -0
  70. data/config/locales/creatives.ko.yml +2 -0
  71. data/config/locales/integrations.en.yml +13 -2
  72. data/config/locales/integrations.ko.yml +13 -2
  73. data/config/routes.rb +12 -0
  74. data/db/migrate/20260609000000_drop_action_text_rich_texts.rb +20 -0
  75. data/db/migrate/20260609005000_add_session_id_to_topics.rb +16 -0
  76. data/db/migrate/20260609010000_create_agent_subscriptions.rb +19 -0
  77. data/db/migrate/20260609020000_add_last_seen_at_to_agent_subscriptions.rb +23 -0
  78. data/db/migrate/20260609030000_add_session_id_to_agent_subscriptions.rb +17 -0
  79. data/db/migrate/20260609190659_backfill_creative_files_into_description.rb +24 -0
  80. data/lib/collavre/engine.rb +0 -1
  81. data/lib/collavre/integration_settings/key_definition.rb +6 -0
  82. data/lib/collavre/integration_settings/registry.rb +7 -2
  83. data/lib/collavre/version.rb +1 -1
  84. metadata +31 -2
  85. data/app/services/collavre/openclaw_abort_service.rb +0 -45
@@ -0,0 +1,777 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Api
5
+ module V1
6
+ class AgentsController < BaseController
7
+ # POST /api/v1/agent/register
8
+ # Registers a Claude Code session, separating two identities:
9
+ # - Agent (agent_name): the user-facing unit. One shared ai_user per
10
+ # (human, agent_name). Without an explicit AGENT_NAME the plugin
11
+ # sends a default name so every session collapses onto one agent.
12
+ # - Session (session_id): one Topic per Claude Code session, keyed by
13
+ # a stable id (derived per cwd by the plugin, stable across
14
+ # --resume). Multiple session topics can share one primary_agent.
15
+ #
16
+ # Back-compat: older plugins send only :name. It is treated as BOTH the
17
+ # agent and the session identity, reproducing the prior
18
+ # one-agent-one-topic-per-name behavior.
19
+ def register
20
+ agent_name = params[:agent_name].to_s.strip
21
+ session_id = params[:session_id].to_s.strip
22
+ legacy_name = params[:name].to_s.strip
23
+ agent_name = legacy_name if agent_name.blank?
24
+ session_id = legacy_name if session_id.blank?
25
+
26
+ if agent_name.blank? || session_id.blank?
27
+ render json: { error: "name (or agent_name and session_id) is required" },
28
+ status: :unprocessable_entity
29
+ return
30
+ end
31
+
32
+ ai_user = find_or_create_session_agent(agent_name)
33
+ unless ai_user
34
+ render json: { error: "Session agent email is already in use by a different account" }, status: :conflict
35
+ return
36
+ end
37
+
38
+ inbox = Creative.inbox_for(current_user)
39
+ topic = find_or_create_session_topic(inbox, ai_user, session_id, params[:session_label])
40
+ topic.unarchive! if topic.archived?
41
+ topic.set_primary_agent!(ai_user)
42
+
43
+ # find_or_create_by! keeps the SELECT-first path (so a sequential
44
+ # re-register reuses the existing share without tripping CreativeShare's
45
+ # model-level user_id/creative_id uniqueness validation). But concurrent
46
+ # sibling registrations can both pass that SELECT before either commits;
47
+ # the loser then hits the DB unique index. Rescue that race and re-find
48
+ # instead of surfacing a 500/conflict.
49
+ begin
50
+ CreativeShare.find_or_create_by!(creative: inbox, user: ai_user) do |s|
51
+ s.permission = :feedback
52
+ s.shared_by = current_user
53
+ end
54
+ rescue ActiveRecord::RecordNotUnique
55
+ CreativeShare.find_by!(creative: inbox, user: ai_user)
56
+ end
57
+
58
+ render json: {
59
+ agent_id: ai_user.id,
60
+ agent_name: ai_user.name,
61
+ topic_id: topic.id,
62
+ topic_name: topic.name,
63
+ session_id: session_id,
64
+ inbox_creative_id: inbox.id,
65
+ ws_url: "/cable"
66
+ }, status: :ok
67
+ end
68
+
69
+ # DELETE /api/v1/agent/:id
70
+ # Archives the agent's topic (session ended).
71
+ # topic_id is required: register reuses the same ai_user across all
72
+ # sessions for the same human, so the primary_agent association alone
73
+ # cannot identify which session is ending. Without an explicit topic_id
74
+ # we could archive a sibling session's active topic.
75
+ def destroy
76
+ ai_user = User.find_by(id: params[:id])
77
+ unless ai_user&.ai_user? && ai_user.created_by_id == current_user.id
78
+ render json: { error: "Agent not found" }, status: :not_found
79
+ return
80
+ end
81
+
82
+ if params[:topic_id].blank?
83
+ render json: { error: "topic_id is required" }, status: :unprocessable_entity
84
+ return
85
+ end
86
+
87
+ inbox = Creative.inbox_for(current_user)
88
+ topic = inbox.topics.active.find_by(id: params[:topic_id])
89
+ # Ensure the topic actually belongs to this agent so a mismatched
90
+ # topic_id can't archive an unrelated inbox conversation.
91
+ topic = nil unless topic && topic.primary_agent&.id == ai_user.id
92
+
93
+ # The plugin closes the WebSocket and IMMEDIATELY calls DELETE, so
94
+ # AgentChannel#unsubscribed may not have dropped this session's presence
95
+ # row yet. Drop it up front — keyed by the ending session's id (sent by
96
+ # the plugin, or derived from the required topic) — so this session's
97
+ # own still-live row cannot masquerade as a live sibling and skip the
98
+ # last-session teardown (which would pin routing_expression on and, if
99
+ # the WS close is never processed, leave nothing to clear it). Done
100
+ # under the agent row lock to serialize against a concurrent subscribe/
101
+ # unsubscribe on the shared agent — the same lock AgentChannel uses.
102
+ exiting_session_id = params[:session_id].presence || topic&.session_id
103
+ last_session = false
104
+ ai_user.with_lock do
105
+ if exiting_session_id.present?
106
+ AgentSubscription
107
+ .where(agent_id: ai_user.id, session_id: exiting_session_id)
108
+ .delete_all
109
+ end
110
+
111
+ unless sibling_sessions_live?(ai_user)
112
+ last_session = true
113
+ # Last (or only) session for this agent. Clear routing_expression
114
+ # FIRST so Orchestration::Matcher#match_by_expression stops
115
+ # dispatching new comments to this now-clientless agent while we
116
+ # drain its task graph below; otherwise a comment arriving mid-drain
117
+ # could enqueue a new task right after we cancelled the last one.
118
+ # (Routing is reactivated on the next AgentChannel subscribe, not on
119
+ # re-register.)
120
+ ai_user.update_column(:routing_expression, nil) if ai_user.routing_expression.present?
121
+ end
122
+ end
123
+
124
+ if last_session
125
+ # Cancel queued/pending tasks BEFORE draining topic queues. The topic
126
+ # queue (Task.queued_for_topic) is scoped by topic_id, not agent_id,
127
+ # so dequeue_next_for_topic would otherwise flip a queued task for
128
+ # this clientless agent to pending and enqueue AiAgentJob — which then
129
+ # broadcasts to a stream with no subscriber and strands the task in
130
+ # delegated until stuck recovery.
131
+ cancel_pending_tasks_for_session(ai_user)
132
+
133
+ # Fail any tasks still delegated to this agent — including dispatches
134
+ # routed to *work* topics outside the registration inbox. With no live
135
+ # session left, agent_id uniquely scopes this agent's delegated work;
136
+ # scoping by topic_id alone would leak the common case (a /work
137
+ # dispatch on a project topic) until stuck detection times out.
138
+ cancel_delegated_tasks_for_session(ai_user)
139
+ elsif topic
140
+ # One shared agent fans out to many concurrent sessions (the default
141
+ # AGENT_NAME case). Another session is still LIVE, so agent-wide
142
+ # cleanup would clear the shared routing_expression and cancel the
143
+ # sibling's in-flight work — exactly the hazard AgentChannel#unsubscribed
144
+ # guards against via presence. Tear down ONLY this ending session:
145
+ # cancel work on its own topic (whose client is gone, so it would
146
+ # otherwise hold a slot / block the topic queue until stuck recovery).
147
+ # The shared routing and any work on other topics belong to the live
148
+ # sibling and are left untouched.
149
+ cancel_tasks_for_topic(ai_user, topic)
150
+ end
151
+ topic.archive! if topic
152
+
153
+ head :no_content
154
+ end
155
+
156
+ # POST /api/v1/agent/reply
157
+ # Creates a comment in a topic as the AI agent
158
+ def reply
159
+ topic = Topic.find_by(id: params[:topic_id])
160
+ unless topic
161
+ render json: { error: "Topic not found" }, status: :not_found
162
+ return
163
+ end
164
+
165
+ creative = topic.creative&.effective_origin
166
+ unless creative
167
+ render json: { error: "Creative not found" }, status: :not_found
168
+ return
169
+ end
170
+
171
+ unless creative.has_permission?(current_user, :feedback)
172
+ render json: { error: "Not authorized" }, status: :forbidden
173
+ return
174
+ end
175
+
176
+ agent = resolve_reply_agent(topic, params[:task_id])
177
+ unless agent
178
+ render json: { error: "Not authorized" }, status: :forbidden
179
+ return
180
+ end
181
+
182
+ # Atomically claim the delegated task BEFORE saving the reply.
183
+ # Two concurrent /reply requests with the same task_id can both
184
+ # pass resolve_reply_agent (which is a read-only scope check) and,
185
+ # without an atomic WHERE status='delegated' transition, both would
186
+ # save separate comments and both would run completion logic —
187
+ # producing duplicate linked replies for one dispatch. Claim first,
188
+ # save second, so the loser sees rows_updated == 0 and bails out
189
+ # before touching the comments table.
190
+ claimed_task = claim_delegated_task(agent, topic, params[:task_id])
191
+ if params[:task_id].present? && claimed_task.nil?
192
+ render json: { error: "Task already completed or not delegated" }, status: :conflict
193
+ return
194
+ end
195
+
196
+ comment = creative.comments.build(
197
+ content: params[:text].to_s,
198
+ topic: topic,
199
+ user: agent,
200
+ skip_default_user: true,
201
+ skip_dispatch: true
202
+ )
203
+
204
+ if comment.save
205
+ finalize_claimed_task(agent, claimed_task, comment) if claimed_task
206
+ dispatch_a2a(agent, comment)
207
+ render json: { comment_id: comment.id }, status: :created
208
+ else
209
+ # Restore the dispatch so the MCP client can retry — the failure
210
+ # is text-level (validation), not task-level.
211
+ claimed_task&.update!(status: "delegated")
212
+ render json: { errors: comment.errors.full_messages }, status: :unprocessable_entity
213
+ end
214
+ end
215
+
216
+ # POST /api/v1/agent/notify
217
+ # Posts an out-of-band informational comment to a topic as the agent
218
+ # WITHOUT completing any task or dispatching. Unlike /reply, this never
219
+ # touches the task graph: it surfaces notices (e.g. native channel
220
+ # permission prompts relayed by Claude Code mid-turn) into the topic
221
+ # chat while the originating task stays in flight. skip_dispatch avoids
222
+ # spawning a new delegated task for the agent's own message.
223
+ def notify
224
+ topic = Topic.find_by(id: params[:topic_id])
225
+ unless topic
226
+ render json: { error: "Topic not found" }, status: :not_found
227
+ return
228
+ end
229
+
230
+ creative = topic.creative&.effective_origin
231
+ unless creative
232
+ render json: { error: "Creative not found" }, status: :not_found
233
+ return
234
+ end
235
+
236
+ unless creative.has_permission?(current_user, :feedback)
237
+ render json: { error: "Not authorized" }, status: :forbidden
238
+ return
239
+ end
240
+
241
+ # The poster must be this session's own Claude Channel agent, owned by
242
+ # the token holder — so /notify can't be used to ventriloquize an
243
+ # unrelated agent or post into a topic the caller doesn't drive.
244
+ agent = resolve_notify_agent(topic, params[:task_id])
245
+ unless agent
246
+ render json: { error: "Not authorized" }, status: :forbidden
247
+ return
248
+ end
249
+
250
+ comment =
251
+ if params[:permission_request_id].present?
252
+ build_permission_comment(creative, topic, agent)
253
+ else
254
+ creative.comments.build(
255
+ content: params[:text].to_s,
256
+ topic: topic,
257
+ user: agent,
258
+ skip_default_user: true,
259
+ skip_dispatch: true
260
+ )
261
+ end
262
+
263
+ if comment.save
264
+ park_pending_permission(topic, agent, params[:task_id], params[:permission_request_id])
265
+ render json: { comment_id: comment.id }, status: :created
266
+ else
267
+ render json: { errors: comment.errors.full_messages }, status: :unprocessable_entity
268
+ end
269
+ end
270
+ private
271
+
272
+ # Build a structured tool-permission comment that reuses the native
273
+ # approval UI (approver gate + approve/deny buttons). The prompt text is
274
+ # rendered server-side via I18n (localized for the viewer), not formatted
275
+ # by the plugin. The action payload carries the request_id so the
276
+ # eventual approve/deny relays the exact decision to the suspended
277
+ # session. approver is the token holder driving this Claude session — the
278
+ # only human who should resolve its prompts.
279
+ def build_permission_comment(creative, topic, agent)
280
+ tool_name = params[:tool_name].to_s.strip.presence || "tool"
281
+ args_raw = sanitize_permission_arguments(params[:arguments])
282
+ description = params[:description].to_s.strip
283
+
284
+ action_payload = {
285
+ "action" => Comment::ClaudeChannelPermission::ACTION_TYPE,
286
+ "request_id" => params[:permission_request_id].to_s,
287
+ "tool_name" => tool_name,
288
+ "description" => description,
289
+ "arguments" => args_raw
290
+ }
291
+
292
+ # The API base controller authenticates the bearer token but does not
293
+ # run the host app's locale switching, so I18n.locale is the process
294
+ # default here. Render the persisted prompt in the token holder's
295
+ # locale explicitly — they are the approver who will read it.
296
+ content = I18n.with_locale(current_user&.locale.presence || I18n.default_locale) do
297
+ I18n.t(
298
+ "collavre.claude_channel.permission.message",
299
+ tool_name: format_permission_tool_name(tool_name),
300
+ description: format_permission_description(description),
301
+ arguments: format_permission_arguments(args_raw)
302
+ )
303
+ end
304
+
305
+ creative.comments.build(
306
+ content: content,
307
+ topic: topic,
308
+ user: agent,
309
+ approver: current_user,
310
+ action: JSON.pretty_generate(action_payload),
311
+ skip_default_user: true,
312
+ skip_dispatch: true
313
+ )
314
+ end
315
+
316
+ # Coerce the arguments param into a JSON-safe value: a permitted Hash, a
317
+ # plain string, or nil. ActionController::Parameters must be unwrapped or
318
+ # JSON.pretty_generate raises on unpermitted parameters.
319
+ def sanitize_permission_arguments(arguments)
320
+ return nil if arguments.blank?
321
+
322
+ arguments.respond_to?(:to_unsafe_h) ? arguments.to_unsafe_h : arguments
323
+ end
324
+
325
+ # Render the (already sanitized) tool arguments for the prompt body. The
326
+ # result is interpolated inside a ```json fence and the comment is later
327
+ # passed through renderCommentMarkdown, so the value must never be able to
328
+ # close that fence. A string input_preview (the documented shape for many
329
+ # tools, e.g. a Bash command) is therefore JSON-serialized rather than
330
+ # emitted raw: that escapes embedded newlines to \n, collapsing it to a
331
+ # single line so no payload line can begin a ``` delimiter and break out
332
+ # into live markdown that misleads the approver.
333
+ def format_permission_arguments(arguments)
334
+ return I18n.t("collavre.claude_channel.permission.no_arguments") if arguments.blank?
335
+
336
+ JSON.pretty_generate(arguments)
337
+ end
338
+
339
+ # Render the optional human-readable permission summary Claude Code sends
340
+ # alongside the structured fields. Absent for most tools, so it collapses
341
+ # to an empty string (no stray blockquote); when present it is the only
342
+ # plain-language description of what the approver is allowing.
343
+ def format_permission_description(description)
344
+ return "" if description.blank?
345
+
346
+ # The description renders into a "> %{text}" blockquote, so any newline
347
+ # would drop the remainder onto a fresh line where it could open a
348
+ # heading or ```fence and escape into live markdown. Flatten line breaks
349
+ # to whitespace so the whole summary stays inside the one blockquote line
350
+ # (inline backticks there are harmless — a fence must start a line).
351
+ flattened = description.gsub(/\s*\R\s*/, " ").strip
352
+ I18n.t("collavre.claude_channel.permission.description", text: flattened)
353
+ end
354
+
355
+ # Render the tool name for the prompt. Unlike description/arguments it is
356
+ # interpolated into a "**%{tool_name}**" emphasis span and the comment is
357
+ # later passed through renderCommentMarkdown, so an unescaped value (e.g. a
358
+ # third-party MCP tool name) could close the surrounding "**", or — via an
359
+ # embedded newline — start a fresh-line heading/fence and misrepresent which
360
+ # tool the approver is authorizing. Flatten line breaks to whitespace and
361
+ # backslash-escape markdown metacharacters so the name always renders
362
+ # literally. The raw name is still kept in the action payload.
363
+ def format_permission_tool_name(tool_name)
364
+ flattened = tool_name.gsub(/\s*\R\s*/, " ").strip
365
+ flattened.gsub(/([\\`*_{}\[\]()#+\-.!~>|<])/) { "\\#{$1}" }
366
+ end
367
+
368
+ # When the relayed comment is a native tool-permission prompt (carries a
369
+ # permission_request_id), park the in-flight dispatch as "awaiting a
370
+ # decision" by stamping pending_tool_call on its delegated task.
371
+ # Comment#dispatch_to_orchestration then relays the human's subsequent
372
+ # allow/deny straight to this suspended session instead of queuing it
373
+ # behind the delegated topic slot (which would deadlock — the task holds
374
+ # the slot it is itself waiting to be unblocked on). Cleared when the
375
+ # task is completed (/reply) or cancelled (unregister/stuck recovery).
376
+ def park_pending_permission(topic, agent, requested_task_id, request_id)
377
+ return if request_id.blank? || requested_task_id.blank?
378
+
379
+ task = Task.where(topic_id: topic.id, status: "delegated", agent_id: agent.id)
380
+ .find_by(id: requested_task_id)
381
+ task&.update_column(:pending_tool_call, {
382
+ "request_id" => request_id.to_s,
383
+ "requested_at" => Time.current.iso8601
384
+ })
385
+ end
386
+
387
+ # Identify which Claude Channel agent a /notify posts as.
388
+ #
389
+ # Mirrors resolve_reply_agent's task_id-first resolution but never
390
+ # touches the task graph (notify only authorizes, it does not complete).
391
+ # A native permission prompt can be raised during a dispatch that
392
+ # selected this session via routing_expression on a *work* topic whose
393
+ # primary_agent is unset or a different agent (the case documented on
394
+ # resolve_reply_agent). The echoed active-dispatch task_id authorizes
395
+ # the dispatched session agent directly; without it the topic-
396
+ # primary_agent gate would 403 and the prompt would never surface in
397
+ # Collavre.
398
+ #
399
+ # task_id absent → fall back to topic.primary_agent (the registration
400
+ # inbox default, where the session IS primary — locally-initiated
401
+ # turns). task_id present-but-unresolved → nil (no fall-through), same
402
+ # as resolve_reply_agent: a present id targeting a vanished/foreign task
403
+ # must not silently post as primary_agent.
404
+ def resolve_notify_agent(topic, requested_task_id)
405
+ if requested_task_id.present?
406
+ task = Task.where(topic_id: topic.id, status: "delegated").find_by(id: requested_task_id)
407
+ agent = task&.agent
408
+ return nil unless agent && agent.claude_channel_agent? && agent.created_by_id == current_user.id
409
+
410
+ return agent
411
+ end
412
+
413
+ agent = topic.primary_agent
414
+ return agent if agent&.claude_channel_agent? && agent.created_by_id == current_user.id
415
+
416
+ nil
417
+ end
418
+
419
+ # Identify which Claude Channel agent this reply is for.
420
+ #
421
+ # Prefer the echoed task_id from the dispatch payload: the matcher can
422
+ # route to a Claude Channel agent via routing_expression on a topic
423
+ # whose primary_agent is unset or a different agent (e.g. multiple AI
424
+ # agents share a topic, or this agent only has feedback permission on
425
+ # the creative without being the topic's primary). In those cases the
426
+ # topic.primary_agent gate would 403 the reply and leave the delegated
427
+ # task hanging.
428
+ #
429
+ # When task_id is provided, look it up scoped to this topic and the
430
+ # delegated state, then take the agent from the task. The token holder
431
+ # is still required to own the agent (created_by_id == current_user.id)
432
+ # and the agent must be a Claude Channel agent so this endpoint can't
433
+ # be used to ventriloquize an unrelated AI agent.
434
+ #
435
+ # When task_id is absent, fall back to topic.primary_agent for
436
+ # back-compat with older plugin builds that don't echo task_id.
437
+ #
438
+ # If task_id IS supplied but the delegated task lookup fails (late
439
+ # reply after cancel/timeout, stale id, wrong topic), do NOT fall
440
+ # through to primary_agent — a present-but-unresolved task_id means
441
+ # the client believes it is answering a specific dispatch that no
442
+ # longer exists. Saving the reply against primary_agent would create
443
+ # an unlinked comment while complete_delegated_task finds nothing,
444
+ # potentially duplicating a reply after the task was already
445
+ # completed/cancelled. Return nil so reply renders 403.
446
+ def resolve_reply_agent(topic, requested_task_id)
447
+ if requested_task_id.present?
448
+ task = Task.where(topic_id: topic.id, status: "delegated").find_by(id: requested_task_id)
449
+ agent = task&.agent
450
+ return nil unless agent && agent.claude_channel_agent? && agent.created_by_id == current_user.id
451
+
452
+ return agent
453
+ end
454
+
455
+ # Legacy fallback (no task_id) must apply the same Claude Channel
456
+ # gate as the task_id path above. Without it, a feedback-permission
457
+ # caller could POST /reply on an ordinary topic whose primary_agent
458
+ # is a normal AI user and save an unlinked comment authored as that
459
+ # agent. Restrict to Claude Channel agents owned by the caller.
460
+ agent = topic.primary_agent
461
+ return agent if agent&.claude_channel_agent? && agent.created_by_id == current_user.id
462
+
463
+ nil
464
+ end
465
+
466
+ # Atomically claim a delegated task for completion. Two-step:
467
+ # 1. Find a candidate task in delegated state, scoped to this
468
+ # agent + topic. With task_id supplied, exact match (required
469
+ # under topic concurrency > 1 where multiple delegated tasks
470
+ # coexist; the client echoes the dispatch's task_id). Without
471
+ # task_id (legacy clients), oldest-first.
472
+ # 2. Inside a transaction: SELECT FOR UPDATE the row, re-check
473
+ # status == 'delegated' under the lock, then update! to 'done'.
474
+ # Concurrent claimers block on the lock; the loser sees the
475
+ # already-flipped status post-lock and returns nil so the
476
+ # caller can refuse the duplicate.
477
+ # update_all (NOT update!) is required to skip Task's
478
+ # after_update_commit callbacks at claim time. The callbacks fire
479
+ # check_trigger_loop_completion (which enqueues TriggerLoopCheckJob)
480
+ # and broadcast_stop_button_removal (which reads reply_comment).
481
+ # Both depend on the reply comment already existing — but reply()
482
+ # claims BEFORE comment.save to win the race against concurrent
483
+ # /reply calls. If update! fired the trigger-loop check here, the
484
+ # job could run (cooldown_seconds: 0) before comment.save commits,
485
+ # find no agent comment, and leave the loop stuck in "running".
486
+ # finalize_claimed_task replays both callbacks after the comment is
487
+ # persisted via Task#fire_completion_callbacks_after_external_claim.
488
+ def claim_delegated_task(agent, topic, requested_task_id)
489
+ scope = Task.where(agent_id: agent.id, topic_id: topic.id, status: "delegated")
490
+ candidate =
491
+ if requested_task_id.present?
492
+ scope.find_by(id: requested_task_id)
493
+ else
494
+ scope.order(:created_at).first
495
+ end
496
+ return nil unless candidate
497
+
498
+ claimed = nil
499
+ Task.transaction do
500
+ locked = Task.lock.find_by(id: candidate.id)
501
+ next unless locked && locked.status == "delegated"
502
+
503
+ Task.where(id: locked.id).update_all(status: "done", pending_tool_call: nil, updated_at: Time.current)
504
+ claimed = locked.reload
505
+ end
506
+ claimed
507
+ end
508
+
509
+ # Post-claim side effects, run only after the reply comment is saved.
510
+ # Links the comment to the claimed task, releases the ResourceTracker
511
+ # slot the AiAgentJob held under task.id, advances the parent
512
+ # workflow (if any), and drains the topic queue — mirroring
513
+ # AiAgentJob#perform's success path for non-delegated runs.
514
+ def finalize_claimed_task(agent, task, comment)
515
+ comment.update_column(:task_id, task.id)
516
+
517
+ Orchestration::ResourceTracker.for(agent).release!(task.id)
518
+
519
+ if task.parent_task_id.present?
520
+ Collavre::Comments::WorkflowExecutor.new(task.parent_task).complete_subtask!(task)
521
+ end
522
+
523
+ Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
524
+
525
+ # Replay the after_update_commit callbacks that were bypassed by
526
+ # update_all in claim_delegated_task — now that the reply comment
527
+ # is linked, TriggerLoopCheckJob can read it and decide whether to
528
+ # advance/await/complete the drop-trigger loop, and the stop-button
529
+ # broadcast has a comment to render.
530
+ task.fire_completion_callbacks_after_external_claim
531
+
532
+ # Clear the typing indicator immediately on reply. ClaudeChannelPresenceJob
533
+ # would also stop on its next beat (task no longer "delegated"), but that
534
+ # is up to HEARTBEAT_SECONDS away — broadcast idle now so the indicator
535
+ # drops the moment Claude's reply lands.
536
+ broadcast_claude_idle(agent, task, comment)
537
+ end
538
+
539
+ # Clear the chat typing indicator via the canonical status broadcaster
540
+ # (the same one AiAgentService uses for every other agent), so the Claude
541
+ # path emits an identical "idle" agent_status payload.
542
+ def broadcast_claude_idle(agent, task, comment)
543
+ creative = comment.creative&.effective_origin
544
+ return unless creative
545
+
546
+ AiAgent::AgentLifecycleManager.new(task: task, agent: agent, creative: creative)
547
+ .broadcast_status("idle")
548
+ end
549
+
550
+ # When an MCP session unregisters, any tasks still in delegated state
551
+ # are abandoned — the client that would have called /reply is gone.
552
+ # Mirror the cancel path so the agent's slot and each topic's queue
553
+ # both free up immediately rather than waiting for stuck detection.
554
+ # Scopes by agent_id only: per-session ai_users mean every delegated
555
+ # task for this agent belongs to this session, on any topic.
556
+ # True when a concurrent session still drives this shared agent. Reaps
557
+ # crash-orphaned presence rows first so a dead process's leftover row
558
+ # can't fool the gate into preserving routing for a sibling that is gone.
559
+ def sibling_sessions_live?(agent)
560
+ AgentSubscription.reap_stale!(agent.id)
561
+ AgentSubscription.live.where(agent_id: agent.id).exists?
562
+ end
563
+
564
+ # Scope cancellation to one ending session's topic (used when a live
565
+ # sibling shares the agent, so an agent-wide sweep is unsafe). Tasks on
566
+ # this topic are unambiguously the ending session's — its session topic
567
+ # is private to it.
568
+ def cancel_tasks_for_topic(agent, topic)
569
+ cancel_pending_tasks(Task.where(topic_id: topic.id, status: %w[queued pending running]), agent)
570
+ cancel_delegated_tasks(Task.where(topic_id: topic.id, status: "delegated"), agent)
571
+ end
572
+
573
+ def cancel_delegated_tasks_for_session(agent)
574
+ cancel_delegated_tasks(Task.where(agent_id: agent.id, status: "delegated"), agent)
575
+ end
576
+
577
+ def cancel_delegated_tasks(tasks, agent)
578
+ return if tasks.empty?
579
+
580
+ tracker = Orchestration::ResourceTracker.for(agent)
581
+ tasks.find_each do |task|
582
+ task.update!(status: "cancelled", pending_tool_call: nil)
583
+ tracker.release!(task.id)
584
+
585
+ if task.parent_task_id.present?
586
+ begin
587
+ Collavre::Comments::WorkflowExecutor.new(task.parent_task).fail_subtask!(
588
+ task, error_message: "Claude Channel session unregistered before reply"
589
+ )
590
+ rescue StandardError => e
591
+ Rails.logger.error(
592
+ "[AgentsController] fail_subtask! failed for task #{task.id}: #{e.message}"
593
+ )
594
+ end
595
+ end
596
+
597
+ Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
598
+ end
599
+ end
600
+
601
+ # Cancel pre-delegation tasks for this session's agent:
602
+ # - queued: waiting in topic queue, no slot reserved
603
+ # - pending: between dequeue and AiAgentJob#perform, no slot reserved
604
+ # - running: AiAgentJob is mid-perform; for Claude Channel agents the
605
+ # slot is reserved with task.id between tracker.reserve! and the
606
+ # "running → delegated" transition. AiAgentJob#perform reloads the
607
+ # task before the delegated transition and exits if it sees
608
+ # "cancelled", so we close that race here.
609
+ #
610
+ # Release the tracker slot defensively for running tasks — release! is
611
+ # idempotent (Set#delete no-ops on missing key) and we don't know from
612
+ # the DB whether AiAgentJob had already reached tracker.reserve!.
613
+ # Parent subtasks are still failed so workflow executors don't deadlock
614
+ # waiting for a reply that will never come.
615
+ def cancel_pending_tasks_for_session(agent)
616
+ cancel_pending_tasks(Task.where(agent_id: agent.id, status: %w[queued pending running]), agent)
617
+ end
618
+
619
+ def cancel_pending_tasks(tasks, agent)
620
+ return if tasks.empty?
621
+
622
+ tracker = Orchestration::ResourceTracker.for(agent)
623
+ drained_topics = {}
624
+ tasks.find_each do |task|
625
+ was_running = task.status == "running"
626
+ task.update!(status: "cancelled")
627
+ tracker.release!(task.id) if was_running
628
+ drained_topics[task.topic_id] ||= task.creative_id
629
+
630
+ if task.parent_task_id.present?
631
+ begin
632
+ Collavre::Comments::WorkflowExecutor.new(task.parent_task).fail_subtask!(
633
+ task, error_message: "Claude Channel session unregistered before dispatch"
634
+ )
635
+ rescue StandardError => e
636
+ Rails.logger.error(
637
+ "[AgentsController] fail_subtask! failed for queued task #{task.id}: #{e.message}"
638
+ )
639
+ end
640
+ end
641
+ end
642
+
643
+ # Drain each affected topic's queue once, AFTER all of this session's
644
+ # pre-dispatch tasks are cancelled. The delegated path dequeues per
645
+ # task, but it never cancels queued tasks; this path does. A per-task
646
+ # dequeue here could promote one of our own still-queued tasks to
647
+ # pending mid-loop, only for the loop to cancel it — and its AiAgentJob
648
+ # early-returns on "cancelled" without dequeuing, re-stranding a live
649
+ # sibling's queued task on the same shared topic. Draining once at the
650
+ # end (when none of our tasks remain queued) promotes the sibling's
651
+ # task instead of ours.
652
+ drained_topics.each do |topic_id, creative_id|
653
+ Orchestration::AgentOrchestrator.dequeue_next_for_topic(topic_id, creative_id)
654
+ end
655
+ end
656
+
657
+ def dispatch_a2a(agent, comment)
658
+ AiAgent::A2aDispatcher.new(
659
+ agent: agent,
660
+ reply_comment: comment,
661
+ context: {
662
+ "creative" => { "id" => comment.creative_id },
663
+ "topic" => { "id" => comment.topic_id }
664
+ }
665
+ ).dispatch
666
+ end
667
+
668
+ # Deterministic, collision-free email key for a (current_user, agent_name)
669
+ # agent. The readable slug is lossy — "qa/bot", "qa bot" and "qa--bot" all
670
+ # squeeze to "qa-bot", and any all-symbol name collapses to "session" — so
671
+ # a short digest of the normalized raw name disambiguates distinct
672
+ # configured names that would otherwise alias onto ONE shared agent
673
+ # identity (ActionCable stream, routing state, tasks, creative shares).
674
+ # The slug stays for human readability; the digest decides identity. Same
675
+ # normalized name -> same key, so idempotent re-register/reuse is
676
+ # preserved. Normalization (strip+downcase) keeps case/whitespace-only
677
+ # differences folded, matching the prior behavior — only the lossy
678
+ # punctuation/spacing collapse is fixed.
679
+ def session_agent_email(agent_name)
680
+ normalized = agent_name.to_s.strip.downcase
681
+ slug = normalized.gsub(/[^a-z0-9-]+/, "-").squeeze("-").gsub(/\A-|-\z/, "")
682
+ slug = "session" if slug.blank?
683
+ digest = Digest::SHA256.hexdigest(normalized)[0, 10]
684
+ "claude-channel-#{current_user.id}-#{slug}-#{digest}@agent.collavre.local"
685
+ end
686
+
687
+ # One ai_user per (current_user, agent_name) so a human's sessions share
688
+ # one Agent identity. Same agent_name re-registering reuses the existing
689
+ # row — idempotent retries (and every additional session) don't
690
+ # proliferate agents.
691
+ #
692
+ # Returns nil when a row with the deterministic email already exists
693
+ # but is owned by someone else or isn't a Claude Channel ai_user. The
694
+ # email format is human-derivable (current_user.id + slug + digest), so a
695
+ # foreign row could be planted by signup/import; silently reusing it
696
+ # would attach the caller's inbox feedback share to that foreign User
697
+ # and leave the plugin's AgentChannel subscription rejected on
698
+ # ownership mismatch. Caller renders 409 in that case.
699
+ def find_or_create_session_agent(agent_name)
700
+ email = session_agent_email(agent_name)
701
+
702
+ existing = User.find_by(email: email)
703
+ return verified_session_agent(existing) if existing
704
+
705
+ # routing_expression: nil so the new ai_user is not matchable until
706
+ # the client claims the per-agent stream via AgentChannel. See the
707
+ # comment on verified_session_agent.
708
+ User.create!(
709
+ email: email,
710
+ name: "Claude Channel (#{agent_name})",
711
+ password: SecureRandom.hex(32),
712
+ llm_vendor: "anthropic",
713
+ llm_model: "claude-code",
714
+ created_by_id: current_user.id,
715
+ searchable: false,
716
+ routing_expression: nil
717
+ )
718
+ rescue ActiveRecord::RecordNotUnique
719
+ # A concurrent registration for the same (user, agent_name) won the
720
+ # users.email unique race. The desired row now exists — re-find and
721
+ # re-verify ownership instead of surfacing a 500 that aborts one of the
722
+ # two simultaneously launching plugin instances.
723
+ verified_session_agent(User.find_by(email: email))
724
+ end
725
+
726
+ # Returns the agent only when it is the current user's own Claude Channel
727
+ # agent; nil otherwise (the caller renders :conflict). Routing activation
728
+ # stays deferred to AgentChannel#subscribe_to_agent_stream so the agent
729
+ # becomes matchable only once a WebSocket subscriber exists for
730
+ # agent:user:<id> — otherwise comments matched between this POST returning
731
+ # and the client's subsequent cable subscribe would broadcast into an
732
+ # empty stream, stranding delegated tasks until stuck recovery.
733
+ def verified_session_agent(ai_user)
734
+ return nil unless ai_user &&
735
+ ai_user.created_by_id == current_user.id &&
736
+ ai_user.ai_user? &&
737
+ ai_user.claude_channel_agent?
738
+
739
+ ai_user
740
+ end
741
+
742
+ # One Topic per (agent, session_id). On re-register (including --resume
743
+ # from the same cwd, which yields the same session_id) the existing
744
+ # topic is reused — even if archived — so the conversation persists
745
+ # instead of orphaning. A fresh session gets a new topic under the same
746
+ # shared agent, which is how one agent fans out to many sessions.
747
+ def find_or_create_session_topic(inbox, ai_user, session_id, session_label)
748
+ existing = inbox.topics.find_by(primary_agent_id: ai_user.id, session_id: session_id)
749
+ return existing if existing
750
+
751
+ label = session_label.to_s.strip.presence || session_id
752
+ inbox.topics.create!(
753
+ name: unique_topic_name(inbox, "Claude #{label}"),
754
+ user: current_user,
755
+ primary_agent_id: ai_user.id,
756
+ session_id: session_id
757
+ )
758
+ end
759
+
760
+ # Topics carry a UNIQUE (creative_id, name) index, so two sessions whose
761
+ # friendly labels collide (e.g. both rooted at a dir named "src") cannot
762
+ # share a name. Append the smallest numeric suffix that is free.
763
+ def unique_topic_name(inbox, desired)
764
+ return desired unless inbox.topics.exists?(name: desired)
765
+
766
+ n = 2
767
+ loop do
768
+ candidate = "#{desired} (#{n})"
769
+ return candidate unless inbox.topics.exists?(name: candidate)
770
+
771
+ n += 1
772
+ end
773
+ end
774
+ end
775
+ end
776
+ end
777
+ end