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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +129 -0
- data/README.md +144 -11
- data/lib/generators/support_desk/install_generator.rb +9 -3
- data/lib/generators/support_desk/templates/create_support_desk_message_registrations.rb.erb +42 -0
- data/lib/generators/support_desk/upgrade_generator.rb +15 -7
- data/lib/support_desk/doctor.rb +30 -3
- data/lib/support_desk/engine.rb +6 -1
- data/lib/support_desk/message_signatures.rb +20 -0
- data/lib/support_desk/models/assistant.rb +43 -8
- data/lib/support_desk/models/draft.rb +9 -0
- data/lib/support_desk/models/message_registration.rb +12 -0
- data/lib/support_desk/models/ticket/assistance.rb +254 -30
- data/lib/support_desk/models/ticket.rb +117 -66
- data/lib/support_desk/transcript.rb +9 -12
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk.rb +50 -7
- data/lib/tasks/support_desk.rake +12 -1
- metadata +5 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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.
|
|
103
|
-
#
|
|
104
|
-
#
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
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?
|