support_desk 0.3.0 → 0.3.2

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.
@@ -52,7 +52,9 @@ module SupportDesk
52
52
  # assistant a host removed from the initializer and kept the history of.
53
53
  def configured? = SupportDesk.config.assistant?(key)
54
54
 
55
- def name = settings_config&.name || key.to_s.humanize
55
+ # What she is called — from configuration while there is any, and from
56
+ # the snapshot below once there is not. See #snapshot_disclosure!.
57
+ def name = settings_config&.name.presence || settings["name"].presence || key.to_s.humanize
56
58
 
57
59
  # Anything `image_tag` accepts, or nil. A callable is passed the record.
58
60
  def avatar
@@ -63,14 +65,41 @@ module SupportDesk
63
65
  # An unconfigured assistant is capped at :off by construction: there is
64
66
  # no rule left saying what she may do, so she may do nothing.
65
67
  def autonomy = settings_config&.autonomy || :off
66
- def disclosure = settings_config&.disclosure
68
+ def disclosure = settings_config&.disclosure || settings["disclosure"]&.to_sym
67
69
  def max_turns = settings_config&.max_turns
68
70
  def responds_within = settings_config&.responds_within
69
71
  def may_open_conversations? = settings_config&.may_open_conversations? || false
70
72
 
71
- def disclosed? = settings_config ? settings_config.disclosed? : false
72
- def signs? = settings_config ? settings_config.signs? : false
73
- def notice? = settings_config ? settings_config.notice? : false
73
+ # Read from configuration while there is any, and from the snapshot
74
+ # otherwise — never simply `false`, because these three decide what a
75
+ # requester is shown next to a message she already sent (R8).
76
+ def disclosed? = disclosure.present? && disclosure != :none
77
+ def signs? = %i[signature_and_notice signature].include?(disclosure)
78
+ def notice? = %i[signature_and_notice notice].include?(disclosure)
79
+
80
+ # Remember what she is called and what is disclosed about her, on her own
81
+ # row. Written whenever configuration resolves her and the two disagree —
82
+ # one UPDATE in the life of a setting, and none on the usual read.
83
+ #
84
+ # Chats asks the AUTHOR for a signature every time a message is rendered,
85
+ # so before this the requester-facing line was whatever configuration said
86
+ # right now: taking her out of the initializer turned "— Rose · virtual
87
+ # assistant" into "— Rose" on messages nobody had touched, and a host
88
+ # reading an old transcript could no longer tell that a machine had
89
+ # written it (R8).
90
+ #
91
+ # Signed messages render their own immutable provenance through the default
92
+ # Chats signature hook. This row snapshot is only a fallback for legacy
93
+ # messages with no provenance and other author displays.
94
+ def snapshot_disclosure! # :nodoc:
95
+ return self unless configured?
96
+
97
+ snapshot = { "name" => name, "disclosure" => disclosure&.to_s }.compact
98
+ return self if snapshot.all? { |field, value| settings[field] == value }
99
+
100
+ update_columns(settings: settings.merge(snapshot))
101
+ self
102
+ end
74
103
 
75
104
  # --- Who she looks like -----------------------------------------------------
76
105
 
@@ -99,10 +128,16 @@ module SupportDesk
99
128
  # policy, not a number of cases.
100
129
  def support_capacity = nil
101
130
 
102
- # Stop her everywhere, now. Cases she holds are released by the silent
103
- # sweep (`SupportDesk.release_silent_assistants!`), which also asks for a
104
- # person on each of them — run it, or wait for its schedule, before
131
+ # Stop her everywhere, now. The cases she is SITTING on are given back by
132
+ # `SupportDesk.reclaim_assistant_seats!` (which
133
+ # `release_silent_assistants!` runs first, and
134
+ # `rake support_desk:reclaim_assistant_seats` runs alone), and it asks
135
+ # for a person on each of them. Run it, or wait for its schedule, before
105
136
  # calling the desk quiet.
137
+ #
138
+ # It needs no `responds_within` and no overdue clock: a seat nobody can
139
+ # sit in any more is not a silence problem. In 0.3.0 it was treated as
140
+ # one, so an assistant with no promise kept her seats for ever (R6).
106
141
  def deactivate!(by:, reason: nil)
107
142
  update!(active: false)
108
143
  SupportDesk.logger&.warn(
@@ -125,9 +125,18 @@ module SupportDesk
125
125
  raise ArgumentError, "an edited draft can't be blank — reject it instead"
126
126
  end
127
127
 
128
+ # The repair commits on its own, so a message whose registration
129
+ # callback was lost is folded in for good even when the approval this
130
+ # call was making is then refused as stale (R3).
131
+ ticket.reconcile_and_commit!
132
+
128
133
  message = nil
129
134
  ticket.with_lock(requires_new: true) do
130
135
  reload
136
+ # Ticket, then conversation: an in-flight customer message holds the
137
+ # conversation row, so `seen_turn` cannot be compared against a case
138
+ # whose next question is one commit away (R1).
139
+ ticket.send(:lock_conversation!)
131
140
  ticket.send(:reconcile_unregistered_messages!)
132
141
  raise InvalidTransition, "draft #{id} is #{status}, not pending" unless pending?
133
142
  unless seen_turn.to_s == ticket.assistant_turn
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk
4
+ # Internal receipt written in the same transaction as the clocks and turn.
5
+ # Message identity establishes idempotency; timestamp/UUID order does not.
6
+ class MessageRegistration < ApplicationRecord
7
+ self.table_name = "support_desk_message_registrations"
8
+ self.primary_key = :message_id
9
+
10
+ belongs_to :ticket, class_name: "SupportDesk::Ticket"
11
+ end
12
+ end
@@ -31,6 +31,49 @@ module SupportDesk
31
31
  # the same whatever the level, and a refusal is always a named reason on
32
32
  # a record (an `assistant_withheld` event, a policy in a draft's
33
33
  # metadata), never silence.
34
+ #
35
+ # == The locking protocol: TICKET, then CONVERSATION
36
+ #
37
+ # The turn is only as good as the reconciliation behind it, and
38
+ # reconciliation is a SELECT: on its own it cannot exclude a requester
39
+ # message that commits one millisecond later. The customer does not take
40
+ # the ticket lock — they press send, and chats writes a message.
41
+ #
42
+ # What chats DOES take is the conversation row. Every message insert
43
+ # updates `chats_conversations` inside its own transaction
44
+ # (`Chats::Message#register_on_conversation`, plus the `messages_count`
45
+ # counter cache), so that row is the serialization point for writes this
46
+ # gem does not own. Every speaking path therefore takes the ticket's row
47
+ # lock and then, before reconciling, the conversation's
48
+ # (`#lock_conversation!`):
49
+ #
50
+ # * a requester message already in flight holds that row, so we block
51
+ # until it commits — and reconciliation then sees it, which makes the
52
+ # turn stale, which is exactly what should happen;
53
+ # * a message that starts after we hold the row blocks until we commit,
54
+ # so our answer is ordered before it and its own turn follows.
55
+ #
56
+ # THE ORDER IS ALWAYS TICKET → CONVERSATION. A requester write locks only
57
+ # the conversation; the after-commit subscriber's `register!` locks the
58
+ # ticket and then, through the system lines a hand-off posts, the
59
+ # conversation. Nothing takes them the other way round, and nothing new
60
+ # may.
61
+ #
62
+ # What it does NOT cover:
63
+ #
64
+ # * A message whose INSERT was already stamped when we win the row is
65
+ # still stamped earlier than our answer, so a transcript can show a
66
+ # question above an answer that did not address it. That is a genuinely
67
+ # simultaneous send, and its registration raises a turn of its own.
68
+ #
69
+ # * SQLITE. All of the above is a `SELECT … FOR UPDATE` blocking a
70
+ # concurrent writer, which is PostgreSQL and MySQL. SQLite has no row
71
+ # locks: it serializes WRITES, and in WAL mode our reconciliation reads
72
+ # the last committed snapshot straight through a requester's open write
73
+ # transaction. So a message committing behind the SELECT is still
74
+ # missed there, exactly as it was in 0.3.0 — the conversation row buys
75
+ # nothing, because nothing waits on it. Run PostgreSQL or MySQL for a
76
+ # production desk with an assistant; `doctor` warns about this.
34
77
  module Assistance
35
78
  extend ActiveSupport::Concern
36
79
 
@@ -62,6 +105,30 @@ module SupportDesk
62
105
  awaiting_reply.where(last_requester_message_at: ..time)
63
106
  .where("assistant_acted_at IS NULL OR assistant_acted_at < last_requester_message_at")
64
107
  }
108
+ # Cases whose conversation holds a requester message the gem never
109
+ # folded in. The registration runs after the message's commit, so it can
110
+ # be LOST — a killed worker, a dropped subscriber — and the clocks then
111
+ # describe a case that no longer exists: `awaiting_requester` on a case
112
+ # that is waiting for an answer, which no idle query can see. This one
113
+ # asks the messages instead of the clocks (R3).
114
+ #
115
+ # Built in Arel rather than as a string, because it compares two tables'
116
+ # columns to each other and there is no value to bind: it has to be
117
+ # readable as "no user input reaches this SQL" at a glance.
118
+ scope :with_unregistered_requester_messages, lambda {
119
+ tickets = arel_table
120
+ messages = Chats::Message.arel_table
121
+ receipts = MessageRegistration.arel_table
122
+ registered = MessageRegistration.select(Arel.sql("1"))
123
+ .where(receipts[:message_id].eq(messages[:id]))
124
+ unregistered = Chats::Message.select(Arel.sql("1"))
125
+ .where(kind: "text")
126
+ .where(messages[:conversation_id].eq(tickets[:conversation_id]))
127
+ .where(messages[:sender_type].eq(tickets[:requester_type]))
128
+ .where(messages[:sender_id].eq(tickets[:requester_id]))
129
+ .where(registered.arel.exists.not)
130
+ where(unregistered.arel.exists)
131
+ }
65
132
  end
66
133
 
67
134
  # --- Readers ------------------------------------------------------------------
@@ -144,10 +211,15 @@ module SupportDesk
144
211
  # on, Locked when there is nobody to write to, NotAnAssistant when
145
212
  # `by:` isn't this desk's assistant.
146
213
  def respond!(body = nil, by:, turn:, files: [], confidence: nil, sources: [], metadata: {}, request: nil)
147
- assistant = resolve_assistant!(by)
148
214
  raise ArgumentError, "respond! needs something to say" if body.blank? && files.blank?
149
215
 
216
+ # Durable repair first, in a transaction of its own: what it folds in
217
+ # survives the StaleTurn that folding it in may cause (R3).
218
+ reconcile_and_commit!
219
+
150
220
  with_lock(requires_new: true) do
221
+ assistant = resolve_assistant!(by)
222
+ lock_conversation!
151
223
  reconcile_unregistered_messages!
152
224
  ensure_current_turn!(turn)
153
225
  ensure_writable!
@@ -168,9 +240,13 @@ module SupportDesk
168
240
  # the draft stays, and so does a person — a conversation that
169
241
  # ran out of turns is one somebody has to finish.
170
242
  if policy.may_reply? && left&.zero?
171
- flag_human_required!(actor: assistant, kind: :escalated, reason: "max_turns",
172
- summary: metadata[:summary] || metadata["summary"],
173
- line: :hand_off_line, request: request)
243
+ handed_off, from = flag_human_required!(actor: assistant, kind: :escalated, reason: "max_turns",
244
+ summary: metadata[:summary] || metadata["summary"],
245
+ line: :hand_off_line, request: request)
246
+ # A conversation that ran out of turns is a hand-off like any
247
+ # other, and it says so out loud: the host's "a person is
248
+ # needed here" notifier listens for this and nothing else (R7).
249
+ publish_escalation!(from: from, reason: :max_turns, by: assistant) if handed_off
174
250
  reason = :max_turns
175
251
  end
176
252
  Outcome.new(action: :drafted, draft: draft, policy: policy, reason: reason, turn: assistant_turn)
@@ -182,10 +258,13 @@ module SupportDesk
182
258
  # `respond!` is what a harness should call; this is for a host that has
183
259
  # already decided it wants a draft. Returns the SupportDesk::Draft.
184
260
  def draft!(body = nil, by:, turn:, files: [], confidence: nil, sources: [], metadata: {}, request: nil)
185
- assistant = resolve_assistant!(by)
186
261
  raise ArgumentError, "draft! needs something to say" if body.blank? && files.blank?
187
262
 
263
+ reconcile_and_commit!
264
+
188
265
  with_lock(requires_new: true) do
266
+ assistant = resolve_assistant!(by)
267
+ lock_conversation!
189
268
  reconcile_unregistered_messages!
190
269
  ensure_current_turn!(turn)
191
270
  ensure_writable!
@@ -208,13 +287,16 @@ module SupportDesk
208
287
  def escalate!(by: nil, reason:, summary: nil, turn: nil, request: nil)
209
288
  actor = resolve_actor(by)
210
289
  ensure_agent!(actor)
211
- assistant = (resolve_assistant!(actor) if SupportDesk.ai_actor?(actor))
290
+ machine = SupportDesk.ai_actor?(actor)
212
291
  raise ArgumentError, "escalate! needs a reason" if reason.blank?
213
292
 
293
+ assistant = nil
214
294
  event = nil
215
295
  from = nil
216
296
  with_lock(requires_new: true) do
217
- if assistant
297
+ if machine
298
+ # Under the lock, from the row (R9).
299
+ assistant = resolve_assistant!(actor)
218
300
  ensure_current_turn!(turn)
219
301
  policy = assistant_policy(assistant)
220
302
  raise AssistantNotAllowed.new(policy, verb: :escalate) unless policy.may_observe?
@@ -226,7 +308,7 @@ module SupportDesk
226
308
  return self unless event
227
309
 
228
310
  stamp_assistant_action! if assistant
229
- SupportDesk.emit_after_commit(:ticket_escalated, self, from: from, reason: reason.to_sym, by: actor)
311
+ publish_escalation!(from: from, reason: reason, by: actor)
230
312
  self
231
313
  end
232
314
 
@@ -325,6 +407,91 @@ module SupportDesk
325
407
  verbs
326
408
  end
327
409
 
410
+ # The silent sweep's own transition: hand this case to a person ONLY if
411
+ # it is still the case the sweep selected — open, still hers, still owing
412
+ # the next word, still past her promise, and with nobody asked for yet.
413
+ # Returns true when it moved. See SupportDesk.release_silent_assistants!.
414
+ #
415
+ # Every one of those predicates was true when the sweep SELECTED its
416
+ # candidates, and a person can answer, take the case or reset the clock
417
+ # between that query and this write. 0.3.0 rechecked only "closed" and
418
+ # "already asked for", so a case somebody had just answered was marked
419
+ # human-required anyway: its priority went up and the customer was told a
420
+ # person was coming, on a case that already had one (R5).
421
+ def escalate_if_still_silent!(assistant, window) # :nodoc:
422
+ event = nil
423
+ from = nil
424
+ with_lock(requires_new: true) do
425
+ next unless open?
426
+ next unless assigned_to?(assistant)
427
+ next unless awaiting_reply?
428
+ next if human_required?
429
+ next unless waiting_since.present? && waiting_since <= window.ago
430
+
431
+ event, from = flag_human_required!(
432
+ actor: :system, kind: :escalated, reason: "assistant_silent",
433
+ summary: "no answer in #{SupportDesk.humanize_duration(window)}",
434
+ line: :hand_off_line, request: nil
435
+ )
436
+ end
437
+ return false unless event
438
+
439
+ publish_escalation!(from: from, reason: "assistant_silent", by: :system)
440
+ true
441
+ end
442
+
443
+ # Give a stranded seat back to people: the assistant holding this case is
444
+ # switched off, no longer declared, or no longer allowed to hold it.
445
+ # Returns true when it moved. See SupportDesk.reclaim_assistant_seats!.
446
+ #
447
+ # This is INVALID-ASSIGNEE recovery, and it is deliberately not silence
448
+ # detection: it needs no `responds_within`, no overdue clock and no
449
+ # waiting side. `deactivate!` promises the seats come back, and in 0.3.0
450
+ # that promise was kept only for an assistant who had a promise of her
451
+ # own and a case that was already late (R6).
452
+ def reclaim_assistant_seat! # :nodoc:
453
+ event = nil
454
+ from = nil
455
+ escalated = false
456
+ with_lock(requires_new: true) do
457
+ next unless open?
458
+ next unless held_by_assistant?
459
+
460
+ from = assignee
461
+ next if assistant_may_keep_seat?(from)
462
+
463
+ if human_required?
464
+ # Somebody has already been asked for, and why is their business —
465
+ # the only thing left to give back is the seat.
466
+ event = write_transition!(:released, actor: :system) do
467
+ release_assistant_seat!(reason: :released)
468
+ { "from" => SupportDesk.actor_key(from), "reason" => "assistant_unavailable" }
469
+ end
470
+ else
471
+ escalated = true
472
+ event, = flag_human_required!(actor: :system, kind: :escalated, reason: "assistant_unavailable",
473
+ summary: nil, line: :hand_off_line, request: nil)
474
+ end
475
+ end
476
+ return false unless event
477
+
478
+ if escalated
479
+ publish_escalation!(from: from, reason: "assistant_unavailable", by: :system)
480
+ else
481
+ SupportDesk.emit_after_commit(:ticket_released, self, from: from, reason: :assistant_unavailable)
482
+ end
483
+ true
484
+ end
485
+
486
+ # The ONE place `:ticket_escalated` is published. Every hand-off that
487
+ # takes her off a case goes through it — her own `escalate!`, the budget
488
+ # branch of `respond!`, the silent sweep — so no path can write the
489
+ # transition and forget the signal (R7). A no-op writes no event and
490
+ # publishes nothing.
491
+ def publish_escalation!(from:, reason:, by:) # :nodoc:
492
+ SupportDesk.emit_after_commit(:ticket_escalated, self, from: from, reason: reason.to_sym, by: by)
493
+ end
494
+
328
495
  # Bump the case's revision — the caller holds the lock. `update_columns`
329
496
  # on purpose: inside the transaction, no callbacks, no validations, and
330
497
  # the in-memory value moves with the row, so the turn a caller reads
@@ -333,6 +500,30 @@ module SupportDesk
333
500
  update_columns(assistant_revision: assistant_revision.to_i + 1)
334
501
  end
335
502
 
503
+ # Fold in every requester message chats has committed but nobody has
504
+ # registered, in a transaction of ITS OWN, and commit it — whatever the
505
+ # answer that discovered it then decides. Returns how many it folded in.
506
+ #
507
+ # A registration lost after commit (a worker killed between the message's
508
+ # COMMIT and the subscriber that registers it) used to be unrepairable:
509
+ # reconciliation ran inside the candidate answer's savepoint, the bumped
510
+ # revision made that very answer stale, and the StaleTurn rolled the
511
+ # repair back with it. The next run read the same revision and did the
512
+ # same thing, for ever (R3).
513
+ #
514
+ # Durability is the caller's: at the top level (a job, the recovery task)
515
+ # this really commits, and the `:assistant_turn` it emits for what it
516
+ # registered is an ACTIONABLE turn. Inside a host's own transaction it is
517
+ # a savepoint like any other, and it commits when that does.
518
+ def reconcile_and_commit! # :nodoc:
519
+ folded = 0
520
+ with_lock(requires_new: true) do
521
+ lock_conversation!
522
+ folded = reconcile_unregistered_messages!
523
+ end
524
+ folded
525
+ end
526
+
336
527
  # When the assistant last did anything here — what the idle-turn check
337
528
  # and the redispatch task read to tell "she decided not to speak" from
338
529
  # "nothing is running".
@@ -358,9 +549,16 @@ module SupportDesk
358
549
  end
359
550
 
360
551
  # Fresh from the row: `active` is a cross-process kill switch, and a
361
- # record loaded a minute ago is not evidence about now.
552
+ # record loaded a minute ago is not evidence about now — nor is one
553
+ # loaded before this call waited for the case's lock. The FRESH
554
+ # record is what is handed back, because the policy reads `active?`
555
+ # off the record it is given (R9).
556
+ #
557
+ # Every caller resolves inside the lock. That still leaves the
558
+ # ordinary in-flight window — a switch that commits after this read
559
+ # and before our own commit wins nothing, and its next check stops
560
+ # the next call — which is documented rather than claimed away.
362
561
  self.class.ensure_agent_record!(actor)
363
- actor
364
562
  end
365
563
 
366
564
  # The turn check, under the lock, from the revision the row holds.
@@ -381,27 +579,50 @@ module SupportDesk
381
579
  # around it. Folding it in here makes the turn stale instead, which is
382
580
  # exactly what should happen.
383
581
  def reconcile_unregistered_messages!
384
- return if conversation.nil?
385
-
386
- scope = conversation.messages.where(kind: "text", sender_type: requester_type, sender_id: requester_id)
387
- if last_requester_message_at.present?
388
- scope = if last_requester_message_id.present?
389
- # Everything after the clock, plus anything sharing its instant
390
- # that ISN'T the message the clock was set from — two messages
391
- # can land on one timestamp, and the second one is real.
392
- scope.where(
393
- "chats_messages.created_at > :at OR (chats_messages.created_at = :at AND chats_messages.id <> :id)",
394
- at: last_requester_message_at, id: last_requester_message_id
395
- )
396
- else
397
- # No pointer to compare against (a 0.2 row whose backfill found
398
- # nothing): the clock alone, rather than a comparison against an
399
- # empty string that some adapters refuse outright.
400
- scope.where("chats_messages.created_at > ?", last_requester_message_at)
401
- end
402
- end
582
+ return 0 if conversation.nil?
583
+
584
+ folded = unregistered_requester_messages.oldest_first.to_a
585
+ folded.each { |message| record_registration!(message) }
586
+ folded.size
587
+ end
588
+
589
+ # Every committed requester message without a receipt, regardless of
590
+ # timestamp or UUID order. The clocks are not evidence of registration.
591
+ def unregistered_requester_messages
592
+ conversation.messages.where(kind: "text", sender_type: requester_type, sender_id: requester_id)
593
+ .where.not(id: message_registrations.select(:message_id))
594
+ end
403
595
 
404
- scope.oldest_first.each { |message| record_registration!(message) }
596
+ # A SELECT … FOR UPDATE on the conversation row, taken AFTER the ticket's
597
+ # and before any reconciliation. See the module comment: this row is what
598
+ # chats updates inside every message's own transaction, so it is the only
599
+ # seam that serializes a customer's write against ours without a write
600
+ # hook in chats (R1).
601
+ #
602
+ # Read through the id rather than the association: `lock!` refuses a
603
+ # record with unsaved changes, and nothing here wants the in-memory
604
+ # conversation reloaded.
605
+ def lock_conversation!
606
+ return if conversation_id.blank?
607
+
608
+ Chats::Conversation.lock.find_by(id: conversation_id)
609
+ end
610
+
611
+ # Whether the assistant sitting on this case may go on sitting on it: she
612
+ # is on duty, she is still declared, and her policy still lets her hold
613
+ # it. Read under the lock, from the reloaded assignee.
614
+ #
615
+ # A policy that RAISES — a desk pointing at an assistant nobody declares
616
+ # any more — is an answer too, and it is "no": a seat nothing can reason
617
+ # about belongs to a person.
618
+ def assistant_may_keep_seat?(holder)
619
+ return false unless holder.is_a?(SupportDesk::Assistant)
620
+ return false unless holder.active?
621
+ return false unless holder.configured?
622
+
623
+ assistant_policy(holder).may_hold?
624
+ rescue StandardError
625
+ false
405
626
  end
406
627
 
407
628
  # Nothing was written, and the reason is on the record: a policy that
@@ -446,6 +667,7 @@ module SupportDesk
446
667
  stamped = { "support_desk" => {
447
668
  "assistant" => assistant.key,
448
669
  "kind" => "ai",
670
+ "name" => assistant.name,
449
671
  "display_name" => assistant.disclosed_name,
450
672
  "disclosure" => assistant.disclosure.to_s,
451
673
  "signed" => assistant.signs?,
@@ -620,6 +842,8 @@ module SupportDesk
620
842
  # turn` before spending money. A hook that raises is reported: a
621
843
  # broken subscriber must not roll back the message that triggered it.
622
844
  def emit_assistant_turn(message = nil)
845
+ return unless open? && awaiting_reply?
846
+
623
847
  assistant = self.assistant
624
848
  return if assistant.nil?
625
849
  return unless assistant_policy(assistant).may_observe?