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.
@@ -59,6 +59,7 @@ module SupportDesk
59
59
  # readonly record refuses to be destroyed. Deleting a ticket is the one
60
60
  # thing that takes its timeline with it, and it needs no callbacks.
61
61
  has_many :events, class_name: "SupportDesk::Event", inverse_of: :ticket, dependent: :delete_all
62
+ has_many :message_registrations, class_name: "SupportDesk::MessageRegistration", dependent: :delete_all
62
63
  has_many :messages, through: :conversation, source: :messages
63
64
 
64
65
  # The assistant surface — the turn, the policy gates, her two verbs and
@@ -341,10 +342,14 @@ module SupportDesk
341
342
 
342
343
  # A loaded instance may predate revocation or deletion. Check the row,
343
344
  # just as requester eligibility does; this does not serialize revocation.
345
+ #
346
+ # Returns the FRESH record, so a caller that goes on to read a runtime
347
+ # flag off it (the assistant's `active?`, which the policy reads) is
348
+ # reading the row and not the copy it was handed (R9).
344
349
  def ensure_agent_record!(record) # :nodoc:
345
350
  if record.respond_to?(:persisted?) && record.persisted? && record.respond_to?(:support_agent?)
346
351
  current = record.class.uncached { record.class.find_by(id: record.id) }
347
- return if current&.support_agent?
352
+ return current if current&.support_agent?
348
353
  end
349
354
 
350
355
  raise NotAnAgent, "#{record.class} is not a currently eligible, persisted support agent — " \
@@ -670,12 +675,10 @@ module SupportDesk
670
675
  # running before any of it.
671
676
  def reply_into!(ticket, message, files:, by:, by_support:, request:, authorize_reuse:)
672
677
  if by_support
673
- # `turn: :current` is for the assistant: reuse turns outreach into
674
- # an ordinary reply, with her full rules — and the turn she has to
675
- # hold is the one this lock reads, because there was never an
676
- # earlier one to give her.
678
+ # Only this private outreach path supplies the current turn internally.
679
+ # Public replies must present the token their harness observed.
677
680
  ticket.send(:reply_under_lock!, message, by: by, files: files, request: request,
678
- authorize: authorize_reuse, turn: :current)
681
+ authorize: authorize_reuse, outreach: true)
679
682
  else
680
683
  ticket.with_lock(requires_new: true) do
681
684
  authorize_reuse&.call(ticket)
@@ -863,6 +866,7 @@ module SupportDesk
863
866
  def reply!(body = nil, by: nil, files: [], request: nil, metadata: {}, turn: nil)
864
867
  actor = resolve_actor(by)
865
868
  ensure_agent!(actor)
869
+ reconcile_and_commit! if SupportDesk.ai_actor?(actor)
866
870
 
867
871
  reply_under_lock!(body, by: actor, files: files, request: request, metadata: metadata, turn: turn)
868
872
  end
@@ -872,11 +876,15 @@ module SupportDesk
872
876
  def note!(body, by: nil, request: nil, turn: nil)
873
877
  actor = resolve_actor(by)
874
878
  ensure_agent!(actor)
875
- assistant = (resolve_assistant!(actor) if SupportDesk.ai_actor?(actor))
879
+ machine = SupportDesk.ai_actor?(actor)
876
880
  raise ArgumentError, "a note needs something to say" if body.blank?
877
881
 
882
+ assistant = nil
878
883
  event = write_transition!(:note, actor: actor, request: request) do
879
- if assistant
884
+ if machine
885
+ # Under the lock, from the row: a kill switch that commits while
886
+ # this call waits for the case is a kill switch that stops it (R9).
887
+ assistant = resolve_assistant!(actor)
880
888
  ensure_current_turn!(turn)
881
889
  policy = assistant_policy(assistant)
882
890
  raise AssistantNotAllowed.new(policy, verb: :note) unless policy.may_observe?
@@ -891,10 +899,9 @@ module SupportDesk
891
899
  # Give the ticket to an agent. `assign!(to: lucia, by: lucia)` is
892
900
  # somebody taking it; `assign!(to: pedro, by: admin)` is somebody being
893
901
  # handed it. Repeating an assignment to the current holder does nothing.
894
- def assign!(to:, by: nil, reason: nil, note: nil, request: nil)
902
+ def assign!(to:, by: nil, reason: nil, note: nil, request: nil, turn: nil)
895
903
  actor = resolve_actor(by)
896
904
  ensure_agent!(actor)
897
- ensure_assistant_may_assign!(actor, to)
898
905
  ensure_assignable!(to)
899
906
 
900
907
  reason ||= to == actor ? :taken : :assigned
@@ -906,6 +913,13 @@ module SupportDesk
906
913
 
907
914
  event = write_transition!(:assigned, actor: actor, request: request) do
908
915
  raise InvalidTransition, "can't assign a closed ticket — reopen it first" if closed?
916
+ # Every mutable question about a seat is asked HERE, under the row
917
+ # lock, from the reloaded row: who holds the case, what her policy
918
+ # says, which turn she is on. Asked before the lock (0.3.0) they
919
+ # were answers about a case that has since moved, and a stale
920
+ # instance could take a seat a person had already been given (R2).
921
+ ensure_assistant_may_assign!(actor, to, turn: turn)
922
+ ensure_assistant_may_hold!(to)
909
923
  next false if assigned_to?(to)
910
924
 
911
925
  assignment = Assignment.open!(ticket: self, agent: to, by: actor, reason: reason, note: note)
@@ -946,6 +960,7 @@ module SupportDesk
946
960
  "(#{assignee ? describe_actor(assignee) : "nobody"} does) — use assign! to override"
947
961
  end
948
962
  raise InvalidTransition, "can't hand off a closed ticket" if closed?
963
+ ensure_assistant_may_hold!(to)
949
964
  next false if assigned_to?(to)
950
965
 
951
966
  from = assignee
@@ -969,14 +984,18 @@ module SupportDesk
969
984
  end
970
985
 
971
986
  # Put the ticket back in the unassigned pile.
972
- def release!(by: nil, reason: :released, request: nil)
987
+ def release!(by: nil, reason: :released, request: nil, turn: nil)
973
988
  actor = resolve_actor(by)
974
989
  ensure_agent!(actor)
975
- assistant = (resolve_assistant!(actor) if SupportDesk.ai_actor?(actor))
976
990
  from = nil
977
991
  event = write_transition!(:released, actor: actor, request: request) do
978
992
  raise InvalidTransition, "can't release a closed ticket" if closed?
993
+ assistant = (resolve_assistant!(actor) if SupportDesk.ai_actor?(actor))
979
994
  if assistant
995
+ # Giving a seat back is an action like any other, so it holds the
996
+ # turn it read: a run that finished after the case moved on must
997
+ # not release the seat a newer one took (R2).
998
+ ensure_current_turn!(turn)
980
999
  policy = assistant_policy(assistant)
981
1000
  raise AssistantNotAllowed.new(policy, verb: :release) unless policy.may_observe?
982
1001
  # Her own seat, and only hers: putting a PERSON's case back in the
@@ -1000,10 +1019,15 @@ module SupportDesk
1000
1019
  def close!(by: nil, request: nil, turn: nil)
1001
1020
  actor = resolve_actor(by)
1002
1021
  ensure_agent!(actor)
1003
- assistant = (resolve_assistant!(actor) if SupportDesk.ai_actor?(actor))
1022
+ machine = SupportDesk.ai_actor?(actor)
1023
+ reconcile_and_commit! if machine
1004
1024
 
1005
1025
  event = write_transition!(:closed, actor: actor, request: request) do
1006
- if assistant
1026
+ if machine
1027
+ # Resolved under the lock, so the kill switch is read after the
1028
+ # wait rather than before it (R9).
1029
+ assistant = resolve_assistant!(actor)
1030
+ lock_conversation!
1007
1031
  reconcile_unregistered_messages!
1008
1032
  ensure_current_turn!(turn)
1009
1033
  policy = assistant_policy(assistant)
@@ -1327,22 +1351,30 @@ module SupportDesk
1327
1351
  end
1328
1352
 
1329
1353
  ensure_agent!(agent)
1354
+ end
1355
+
1356
+ # "Could she hold this case once the human-side flags were lifted?" —
1357
+ # the hand-back question, so the very flags a hand-back exists to clear
1358
+ # are not what refuses it. Her autonomy, the topic and the host's cap
1359
+ # block still decide.
1360
+ #
1361
+ # Asked UNDER THE LOCK (R2): a cap that lands while a take waits for the
1362
+ # row is the cap that applies to it.
1363
+ def ensure_assistant_may_hold!(agent)
1330
1364
  return unless SupportDesk.ai_actor?(agent)
1331
1365
 
1332
- # "Could she hold this case once the human-side flags were lifted?" —
1333
- # the hand-back question, so the very flags a hand-back exists to
1334
- # clear are not what refuses it. Her autonomy, the topic and the host's
1335
- # cap block still decide.
1336
1366
  policy = assistant_policy(agent, hand_back: true)
1337
1367
  raise AssistantNotAllowed.new(policy, verb: :take) unless policy.may_hold?
1338
1368
  end
1339
1369
 
1340
1370
  # An assistant may take a case, and that is all: only herself, only when
1341
- # nobody holds it, and only at a level that may answer.
1342
- def ensure_assistant_may_assign!(actor, to)
1371
+ # nobody holds it, only at a level that may answer, and only holding the
1372
+ # turn she read. Called under the row lock, from the reloaded row.
1373
+ def ensure_assistant_may_assign!(actor, to, turn:)
1343
1374
  return unless SupportDesk.ai_actor?(actor)
1344
1375
 
1345
1376
  assistant = resolve_assistant!(actor)
1377
+ ensure_current_turn!(turn)
1346
1378
  policy = assistant_policy(assistant)
1347
1379
  unless self.class.same_actor?(actor, to)
1348
1380
  raise AssistantNotAllowed.new(policy, verb: :assign,
@@ -1402,12 +1434,16 @@ module SupportDesk
1402
1434
  # `authorize:` is the console's hook (see Ticket.open_or_reply!): it runs
1403
1435
  # under this lock, before any policy side effect, and a raise there rolls
1404
1436
  # the whole thing back.
1405
- def reply_under_lock!(body, by:, files:, request:, authorize: nil, metadata: {}, turn: nil)
1437
+ def reply_under_lock!(body, by:, files:, request:, authorize: nil, metadata: {}, turn: nil, outreach: false)
1406
1438
  with_lock(requires_new: true) do
1407
1439
  authorize&.call(self)
1408
1440
  next assistant_reply_under_lock!(body, assistant: by, files: files, request: request,
1409
- metadata: metadata, turn: turn) if SupportDesk.ai_actor?(by)
1441
+ metadata: metadata, turn: turn, outreach: outreach) if SupportDesk.ai_actor?(by)
1410
1442
 
1443
+ # Fold committed inputs before a human answer too. A delayed callback
1444
+ # must not later turn an already-answered question back into work.
1445
+ lock_conversation!
1446
+ reconcile_unregistered_messages!
1411
1447
  ensure_writable!
1412
1448
  apply_reply_policy!(by, request: request)
1413
1449
  posted = post_agent_message!(body, files: files, by: by, metadata: metadata)
@@ -1423,13 +1459,13 @@ module SupportDesk
1423
1459
  # `reply!` by the assistant: her full rules, under the lock the caller
1424
1460
  # already holds. Everything here is also what `respond!` runs — this is
1425
1461
  # the path for a host that decided to answer rather than ask policy.
1426
- def assistant_reply_under_lock!(body, assistant:, files:, request:, metadata:, turn:)
1462
+ def assistant_reply_under_lock!(body, assistant:, files:, request:, metadata:, turn:, outreach: false)
1427
1463
  assistant = resolve_assistant!(assistant)
1428
- # `:current` means "the turn as this lock sees it" — the one place the
1429
- # gem supplies a turn itself, because an outreach reply into an
1430
- # existing case has no earlier turn for anybody to have held.
1431
- turn = assistant_turn if turn == :current
1464
+ # Private outreach supplies a token only after acquiring both locks;
1465
+ # public replies compare the caller's actual observed token.
1466
+ lock_conversation!
1432
1467
  reconcile_unregistered_messages!
1468
+ turn = assistant_turn if outreach
1433
1469
  ensure_current_turn!(turn)
1434
1470
  ensure_writable!
1435
1471
 
@@ -1458,14 +1494,16 @@ module SupportDesk
1458
1494
  "take it first"
1459
1495
  end
1460
1496
 
1461
- assign!(to: actor, by: actor, request: request)
1497
+ # This private path already validated the reply while holding the lock.
1498
+ # Seat-taking consumes the real current token, not a public sentinel.
1499
+ assign!(to: actor, by: actor, request: request, turn: assistant_turn)
1462
1500
  elsif !assigned_to?(actor)
1463
1501
  # Humans outrank assistants, under EVERY reply policy (I8). A person
1464
1502
  # answering a case a machine is holding takes it over: there is
1465
1503
  # nothing to ask about "who owns this" when one of the two can't
1466
1504
  # want it, and leaving her seated would keep her answering next.
1467
1505
  if held_by_assistant? && !SupportDesk.ai_actor?(actor)
1468
- return assign!(to: actor, by: actor, reason: :drop_in_takeover, request: request)
1506
+ return assign!(to: actor, by: actor, reason: :drop_in_takeover, request: request, turn: assistant_turn)
1469
1507
  end
1470
1508
  # Belt: the `held_by_human` floor already turned this into a draft,
1471
1509
  # so an assistant reaching here is a bug, not a policy question.
@@ -1476,7 +1514,7 @@ module SupportDesk
1476
1514
  raise NotAllowed, "ticket #{reference} is held by #{describe_actor(assignee)} and this desk only " \
1477
1515
  "lets the assignee reply"
1478
1516
  when :take_over
1479
- assign!(to: actor, by: actor, reason: :drop_in_takeover, request: request)
1517
+ assign!(to: actor, by: actor, reason: :drop_in_takeover, request: request, turn: assistant_turn)
1480
1518
  else
1481
1519
  write_transition!(:drop_in, actor: actor, request: request) do
1482
1520
  { "assignee" => SupportDesk.actor_key(assignee) }
@@ -1551,11 +1589,15 @@ module SupportDesk
1551
1589
  # caller is not always the lock holder it thinks it is.
1552
1590
  def record_registration!(message) # :nodoc:
1553
1591
  return self if role_of(message) == :system
1592
+ unless message.persisted? && message.conversation_id == conversation_id
1593
+ raise ArgumentError, "message must belong to this ticket's conversation"
1594
+ end
1554
1595
  # Re-check under the lock: the same message can reach us twice (a
1555
1596
  # redelivered event, a hand-written replay), and an SLA clock that
1556
1597
  # moves twice for one message is a lie.
1557
1598
  return self if registered?(message)
1558
1599
 
1600
+ message_registrations.create!(message_id: message.id)
1559
1601
  role = role_of(message)
1560
1602
  opening = opening_message?
1561
1603
  reopened = false
@@ -1565,10 +1607,10 @@ module SupportDesk
1565
1607
  closed_by_assistant = false
1566
1608
  case role
1567
1609
  when :requester
1568
- attributes[:last_requester_message_at] = message.created_at
1610
+ attributes[:last_requester_message_at] = [ last_requester_message_at, message.created_at ].compact.max
1569
1611
  # The pointer the turn's reconciliation reads: which requester
1570
1612
  # message the clocks are standing on, by id and not only by time.
1571
- attributes[:last_requester_message_id] = message.id
1613
+ attributes[:last_requester_message_id] = message.id if after_requester_watermark?(message)
1572
1614
  if closed? && message.created_at > closed_at && desk_config.closed_tickets == :reopen_on_reply
1573
1615
  # Read before the merge below clears it — see #reopen!.
1574
1616
  closed_by_assistant = closed_by_type == SupportDesk::Assistant.polymorphic_name
@@ -1577,7 +1619,7 @@ module SupportDesk
1577
1619
  reopened = true
1578
1620
  end
1579
1621
  when :agent
1580
- attributes[:last_agent_message_at] = message.created_at
1622
+ attributes[:last_agent_message_at] = [ last_agent_message_at, message.created_at ].compact.max
1581
1623
  # A first REPLY answers something. An agent message with no earlier
1582
1624
  # requester message is the desk opening the conversation, or a word
1583
1625
  # into an empty case — neither is a reply, and a requester clock that
@@ -1592,7 +1634,13 @@ module SupportDesk
1592
1634
  end
1593
1635
 
1594
1636
  assign_attributes(attributes)
1595
- self.awaiting = closed? ? "none" : awaiting_from_clocks
1637
+ self.awaiting = if closed?
1638
+ "none"
1639
+ elsif role == :requester
1640
+ "agent"
1641
+ else
1642
+ awaiting_from_clocks
1643
+ end
1596
1644
  self.waiting_since = waiting_since_from_clocks
1597
1645
  save!
1598
1646
  # Every registered message moves the case on, so every one of them
@@ -1609,48 +1657,51 @@ module SupportDesk
1609
1657
 
1610
1658
  # After the bump, and after any reopen: the hook decides about a case
1611
1659
  # in the state this message left it in.
1612
- evaluate_hand_off_phrase!(message) if role == :requester
1660
+ evaluate_hand_off_phrase!(message) if role == :requester && !closed?
1613
1661
 
1614
1662
  publish_transition(reopen_event, :reopened, requester, nil) if reopen_event
1615
1663
  announce_registration(message, role: role, opening: opening, reopened: reopened)
1616
1664
  self
1617
1665
  end
1618
1666
 
1619
- # Whether this message is already folded in. The last-id check catches
1620
- # the common redelivery; the clock check catches the rest, because a
1621
- # REPLAY can arrive in any order and an older message must never rewind
1622
- # `awaiting`, restart an SLA clock, or reopen a case that was closed
1623
- # after it.
1624
- #
1625
- # The comparison is `<=`, so a message whose timestamp already sits on
1626
- # the clock counts as folded in: idempotency is the documented promise,
1627
- # and the cost of the rare tie is one clock that doesn't advance until
1628
- # the next message.
1667
+ # Receipt identity, not transcript order, decides whether a callback is a
1668
+ # replay. A later commit may have an earlier timestamp or a smaller UUID.
1629
1669
  def registered?(message)
1630
- return true if last_registered_message_id.present? && last_registered_message_id.to_s == message.id.to_s
1631
-
1632
- role = role_of(message)
1633
- clock = case role
1634
- when :requester then last_requester_message_at
1635
- when :agent then last_agent_message_at
1636
- end
1637
- return false if clock.blank?
1638
- return true if message.created_at < clock
1639
- return false unless message.created_at == clock
1640
-
1641
- # Equal timestamps are NOT the same message. A coarse column, a frozen
1642
- # clock in a test, or two very fast inserts can put two different
1643
- # messages on one instant, and a `<=` here folded the second one in
1644
- # without moving anything — so the assistant answered a question the
1645
- # case had never registered. The only equal-timestamp message that
1646
- # counts as folded in is the one the clock was set FROM.
1647
- case role
1648
- when :requester then last_requester_message_id.present? &&
1649
- last_requester_message_id.to_s == message.id.to_s
1650
- else false
1670
+ message_registrations.exists?(message_id: message.id)
1671
+ end
1672
+
1673
+ # Advance the requester clock pointer only in transcript order. Receipt
1674
+ # identity decides registration separately, so late arrivals cannot rewind
1675
+ # clocks or be discarded as replays.
1676
+ def after_requester_watermark?(message)
1677
+ watermark = last_requester_message_at
1678
+ return true if watermark.blank?
1679
+ return true if message.created_at > watermark
1680
+ return false if message.created_at < watermark
1681
+ # A tie with no pointer to break it (a 0.2 row whose backfill found
1682
+ # nothing) is new: the alternative silently drops it.
1683
+ return true if last_requester_message_id.blank?
1684
+
1685
+ compare_message_ids(message.id, last_requester_message_id).positive?
1686
+ end
1687
+
1688
+ # Order two chats message ids the way the DATABASE orders that column,
1689
+ # because the reconciliation query compares them in SQL and this one
1690
+ # compares them in Ruby. Integers compare numerically (10 is after 9);
1691
+ # anything else — a uuid, a ULID — compares as a string, which is how
1692
+ # every adapter orders those columns too.
1693
+ def compare_message_ids(one, other)
1694
+ if one.is_a?(Integer) && other.is_a?(Integer)
1695
+ one <=> other
1696
+ elsif integerish?(one) && integerish?(other)
1697
+ one.to_i <=> other.to_i
1698
+ else
1699
+ one.to_s <=> other.to_s
1651
1700
  end
1652
1701
  end
1653
1702
 
1703
+ def integerish?(value) = value.to_s.match?(/\A-?\d+\z/)
1704
+
1654
1705
  def opening_message?
1655
1706
  last_registered_message_id.blank? && last_requester_message_at.nil? && last_agent_message_at.nil?
1656
1707
  end
@@ -29,14 +29,10 @@ module SupportDesk
29
29
  #
30
30
  # == Names survive renames
31
31
  #
32
- # An assistant's name is read from the message's own provenance stamp
33
- # first (`metadata["support_desk"]["assistant"]`, the KEY), resolved
34
- # through the configuration when she is still declared, and only then
35
- # falls back to the `display_name` the message was written with. So a
36
- # transcript reads "Rose" while Rose is configured, keeps saying what the
37
- # customer was actually shown for an assistant nobody declares any more,
38
- # and never invents a name for a message written under a nameless
39
- # disclosure mode.
32
+ # The assistant's original name is stored on new messages. Older assistant
33
+ # messages fall back to their stored display name, then to configuration or
34
+ # the author record only when there is no message snapshot. Exported turns
35
+ # retain AI provenance even when their requester-facing mode is nameless.
40
36
  #
41
37
  # == Deleted messages are turns too
42
38
  #
@@ -204,12 +200,13 @@ module SupportDesk
204
200
  end
205
201
  end
206
202
 
207
- # The key the message was stamped with, resolved to the name that key
208
- # has NOW — and the stored display name when nothing declares her any
209
- # more. A message written by the record rather than by `speak!` (an
210
- # import, a host that posts its own) has neither, so the record answers.
203
+ # Message snapshots survive configuration changes. Imports without a
204
+ # snapshot use the configured key or author as a legacy fallback.
211
205
  def assistant_name(message)
212
206
  stamp = provenance(message)
207
+ return stamp["name"] if stamp["name"].is_a?(String) && stamp["name"].present?
208
+ return stamp["display_name"] if stamp["display_name"].is_a?(String) && stamp["display_name"].present?
209
+
213
210
  key = stamp["assistant"]
214
211
  if key.present? && SupportDesk.config.assistant?(key)
215
212
  return SupportDesk.config.assistant(key).name
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SupportDesk
4
- VERSION = "0.3.0"
4
+ VERSION = "0.3.2"
5
5
  end
data/lib/support_desk.rb CHANGED
@@ -124,7 +124,11 @@ module SupportDesk
124
124
  "`config.assistant #{key.inspect} do |assistant| … end`"
125
125
  end
126
126
 
127
- assistants[key] ||= Assistant.for(key)
127
+ # Resolving her is also when what she is CALLED is written down, so a
128
+ # message she signed still says the same thing after somebody takes
129
+ # her out of the initializer (R8). It writes only when configuration
130
+ # and the row disagree.
131
+ (assistants[key] ||= Assistant.for(key)).snapshot_disclosure!
128
132
  end
129
133
 
130
134
  # Every assistant record this process has resolved, keyed by key.
@@ -146,6 +150,31 @@ module SupportDesk
146
150
  record.respond_to?(:support_agent_kind) && record.support_agent_kind == :ai
147
151
  end
148
152
 
153
+ # Give back every seat an assistant can no longer sit in — she was
154
+ # switched off, her configuration was removed, or her policy no longer
155
+ # lets her hold a case — and ask for a person on each of them.
156
+ #
157
+ # Invalid-assignee recovery, NOT silence detection: it reads the seats
158
+ # that exist rather than the assistants a running process happens to
159
+ # have configured, so a `deactivate!`, a flag flipped off and a topic
160
+ # cap tightened are all covered, with no `responds_within` and no
161
+ # overdue clock anywhere in it (R6). `release_silent_assistants!` runs
162
+ # it first; `rake support_desk:reclaim_assistant_seats` runs it alone.
163
+ #
164
+ # Returns how many seats it reclaimed.
165
+ def reclaim_assistant_seats!
166
+ return 0 unless Assistant.table_exists?
167
+
168
+ reclaimed = 0
169
+ Ticket.open.held_by_assistants.find_each do |ticket|
170
+ reclaimed += 1 if ticket.reclaim_assistant_seat!
171
+ rescue StandardError => e
172
+ report_error(e, context: { hook: :reclaim_assistant_seats, ticket: ticket.id })
173
+ end
174
+ logger&.info("[support_desk] reclaimed #{reclaimed} stranded assistant seat(s)") if reclaimed.positive?
175
+ reclaimed
176
+ end
177
+
149
178
  # Release every assistant who has sat on a case longer than her
150
179
  # `responds_within` without answering — and ask for a person on it.
151
180
  #
@@ -154,18 +183,19 @@ module SupportDesk
154
183
  # Run it every minute (`rake support_desk:release_silent_assistants`).
155
184
  # Returns how many cases it moved.
156
185
  def release_silent_assistants!
157
- moved = 0
186
+ # A seat nobody can sit in any more is not a silence problem, and it
187
+ # must not need a `responds_within` to be noticed (R6).
188
+ moved = reclaim_assistant_seats!
158
189
  config.assistants.each_key do |key|
159
190
  agent = assistant(key)
160
191
  window = agent&.responds_within
161
192
  next if window.nil?
162
193
 
163
194
  Ticket.open.assigned_to(agent).awaiting_reply.waiting_over(window).find_each do |ticket|
164
- next if ticket.human_required?
165
-
166
- ticket.escalate!(by: :system, reason: "assistant_silent",
167
- summary: "no answer in #{humanize_duration(window)}")
168
- moved += 1 if ticket.human_required?
195
+ # Conditional, under the case's own lock: every predicate above was
196
+ # true when this row was SELECTED, and a person may have answered
197
+ # it since (R5).
198
+ moved += 1 if ticket.escalate_if_still_silent!(agent, window)
169
199
  rescue StandardError => e
170
200
  report_error(e, context: { hook: :release_silent_assistants, ticket: ticket.id })
171
201
  end
@@ -186,6 +216,19 @@ module SupportDesk
186
216
 
187
217
  config.desks.each_key do |key|
188
218
  agent = desk(key)&.assistant
219
+ # Repair before deciding. A requester message whose registration was
220
+ # lost after commit leaves the clocks describing a case that no longer
221
+ # exists — and `assistant_idle_since` reads those clocks, so the case
222
+ # it most needs to find is the one it cannot see. Folding the message
223
+ # in COMMITS on its own and emits the turn it produces, so a dead
224
+ # process followed by nothing but this task still ends in an
225
+ # actionable turn (R3).
226
+ Ticket.for_desk(key).with_unregistered_requester_messages.find_each do |ticket|
227
+ emitted += 1 if ticket.reconcile_and_commit!.positive?
228
+ rescue StandardError => e
229
+ report_error(e, context: { hook: :redispatch_assistant_turns, ticket: ticket.id })
230
+ end
231
+
189
232
  next if agent.nil?
190
233
 
191
234
  Ticket.open.for_desk(key).assistant_idle_since(older_than.ago).find_each do |ticket|
@@ -38,6 +38,17 @@ namespace :support_desk do
38
38
  puts "[support_desk] #{moved} case(s) handed to a person."
39
39
  end
40
40
 
41
+ desc "Give back every seat an assistant can no longer sit in (run every minute)"
42
+ task reclaim_assistant_seats: :environment do
43
+ # Invalid-assignee recovery: she was switched off, her configuration was
44
+ # taken away, or her policy no longer lets her hold a case. It needs no
45
+ # `responds_within` and no overdue clock — `release_silent_assistants`
46
+ # runs it first, and this is the same work on its own.
47
+ reclaimed = SupportDesk.reclaim_assistant_seats!
48
+
49
+ puts "[support_desk] #{reclaimed} stranded seat(s) given back to people."
50
+ end
51
+
41
52
  desc "Re-emit the turn for cases nobody answered (OLDER_THAN=60 seconds; run every 5 minutes)"
42
53
  task redispatch_assistant_turns: :environment do
43
54
  # At-least-once, and that is safe: the turn is consumed by the first
@@ -48,7 +59,7 @@ namespace :support_desk do
48
59
  puts "[support_desk] #{emitted} turn(s) re-emitted (idle for more than #{older_than.inspect})."
49
60
  end
50
61
 
51
- desc "What the assistants are doing right now (read-only)"
62
+ desc "Report assistant status without changing tickets"
52
63
  task assistant_status: :environment do
53
64
  if SupportDesk.config.assistants.empty?
54
65
  puts "[support_desk] no assistant is configured."
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: support_desk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-19 00:00:00.000000000 Z
10
+ date: 2026-09-24 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord
@@ -191,6 +191,7 @@ files:
191
191
  - lib/generators/support_desk/templates/assistant/turn_job_test.rb.erb
192
192
  - lib/generators/support_desk/templates/console/controller.rb.erb
193
193
  - lib/generators/support_desk/templates/console/resource.rb.erb
194
+ - lib/generators/support_desk/templates/create_support_desk_message_registrations.rb.erb
194
195
  - lib/generators/support_desk/templates/create_support_desk_tables.rb.erb
195
196
  - lib/generators/support_desk/templates/initializer.rb
196
197
  - lib/generators/support_desk/upgrade_generator.rb
@@ -209,6 +210,7 @@ files:
209
210
  - lib/support_desk/errors.rb
210
211
  - lib/support_desk/events.rb
211
212
  - lib/support_desk/macros.rb
213
+ - lib/support_desk/message_signatures.rb
212
214
  - lib/support_desk/models/application_record.rb
213
215
  - lib/support_desk/models/assignment.rb
214
216
  - lib/support_desk/models/assistant.rb
@@ -218,6 +220,7 @@ files:
218
220
  - lib/support_desk/models/desk.rb
219
221
  - lib/support_desk/models/draft.rb
220
222
  - lib/support_desk/models/event.rb
223
+ - lib/support_desk/models/message_registration.rb
221
224
  - lib/support_desk/models/ticket.rb
222
225
  - lib/support_desk/models/ticket/assistance.rb
223
226
  - lib/support_desk/outcome.rb