@@ -323,9 +323,10 @@ def _build_deferred_result(
323323 * ,
324324 reason : str ,
325325 detail : str = "" ,
326+ retry : bool = False ,
326327) -> dict :
327328 payload = {
328- "all_done" : True ,
329+ "all_done" : not retry ,
329330 "work_done" : False ,
330331 "hot_path" : True ,
331332 "hot_path_type" : "pre_dispatch_deferred" ,
@@ -337,6 +338,8 @@ def _build_deferred_result(
337338 }
338339 if detail :
339340 payload ["detail" ] = detail
341+ if retry :
342+ payload ["retry_pending" ] = True
340343 return {
341344 "final" : json .dumps (payload , ensure_ascii = False ),
342345 "history" : f"{ cfg .history_prefix } :deferred:{ reason } " ,
@@ -505,6 +508,14 @@ def _pick_first(item: dict, keys: list[str]) -> str:
505508 # duplicate worker invocations and racing replies.
506509 "identity_key" : str (item .get ("identity_key" ) or "" ),
507510 }
511+ for pending_key in (
512+ "pending_timer" ,
513+ "unread_badge" ,
514+ "unread" ,
515+ "needs_action" ,
516+ ):
517+ if pending_key in item :
518+ entry [pending_key ] = item .get (pending_key )
508519 for extra_key in cfg .assignment_extra_fields :
509520 ek = str (extra_key )
510521 if ek and ek not in entry and ek in item :
@@ -908,7 +919,7 @@ def _release_inflight_on_early_exit(reason: str) -> None:
908919 if enrich .skip :
909920 skip_reason = enrich .skip_reason or "unspecified"
910921 _release_inflight_on_early_exit (f"enrich_skip:{ skip_reason } " )
911- if skip_reason == "typing_lock_active" :
922+ if skip_reason in { "typing_lock_active" , "active_customer_mismatch" } :
912923 return "" , "" , _TYPING_LOCK_ACTIVE_SENTINEL
913924 return opened_row , "" , ""
914925 scraped_msg_id = enrich .scraped_msg_id
@@ -1025,19 +1036,21 @@ def _build_result_payload(
10251036 opened_rows : list [str ],
10261037 assigned_rows : list [str ],
10271038 failure_rows : list [str ],
1039+ deferred_rows : list [str ] | None = None ,
10281040) -> dict :
10291041 """Final result dict returned to the caller when dispatch ran
10301042 (even partially). Matches the shape of the legacy inline
10311043 ``_maybe_run_frontdesk_dispatch_fastpath`` return.
10321044 """
1033- return {
1034- "all_done" : True ,
1045+ deferred_rows = deferred_rows or []
1046+ payload = {
1047+ "all_done" : not bool (deferred_rows ),
10351048 "work_result" : {
10361049 cfg .fastpath_marker : True ,
10371050 "visible_session_count" : len (actionable ),
10381051 "opened_count" : len (opened_rows ),
10391052 "assigned_count" : len (assigned_rows ),
1040- "last_action_succeeded" : not bool (failure_rows ),
1053+ "last_action_succeeded" : not bool (failure_rows or deferred_rows ),
10411054 "no_customers" : False ,
10421055 },
10431056 "opened_sessions" : opened_rows ,
@@ -1049,6 +1062,28 @@ def _build_result_payload(
10491062 else f"{ cfg .log_tag } completed with failures"
10501063 ),
10511064 }
1065+ if deferred_rows :
1066+ payload ["work_done" ] = False
1067+ payload ["hot_path" ] = True
1068+ payload ["hot_path_type" ] = "pre_dispatch_deferred"
1069+ payload ["reason" ] = "feige_focus_contention"
1070+ payload ["retry_pending" ] = True
1071+ payload ["deferred_sessions" ] = deferred_rows
1072+ payload ["work_result" ]["deferred_count" ] = len (deferred_rows )
1073+ payload ["message" ] = (
1074+ f"{ cfg .log_tag } partially completed; "
1075+ f"{ len (deferred_rows )} row(s) deferred"
1076+ )
1077+ return payload
1078+
1079+
1080+ def _deferred_row_label (item : dict ) -> str :
1081+ return str (
1082+ item .get ("session_id" )
1083+ or item .get ("customer_name" )
1084+ or item .get ("customer_id" )
1085+ or _TYPING_LOCK_ACTIVE_SENTINEL
1086+ )
10521087
10531088
10541089# ───────────────────────────── orchestrator ───────────────────────────────
@@ -1256,6 +1291,7 @@ async def _run_with_lock_held(
12561291 opened_rows : list [str ] = []
12571292 assigned_rows : list [str ] = []
12581293 failure_rows : list [str ] = []
1294+ deferred_rows : list [str ] = []
12591295 for item in actionable :
12601296 opened , assigned , failure = await _dispatch_one_item (
12611297 item ,
@@ -1272,23 +1308,45 @@ async def _run_with_lock_held(
12721308 if assigned :
12731309 assigned_rows .append (assigned )
12741310 if failure :
1275- failure_rows .append (failure )
1311+ if failure == _TYPING_LOCK_ACTIVE_SENTINEL :
1312+ deferred_rows .append (_deferred_row_label (item ))
1313+ else :
1314+ failure_rows .append (failure )
12761315
12771316 if not opened_rows and not assigned_rows and not failure_rows :
1317+ if deferred_rows :
1318+ return _build_deferred_result (
1319+ cfg ,
1320+ reason = "feige_focus_contention" ,
1321+ detail = f"{ len (deferred_rows )} row(s) deferred" ,
1322+ retry = True ,
1323+ )
12781324 return None
12791325
1280- payload = _build_result_payload (cfg , actionable , opened_rows , assigned_rows , failure_rows )
1326+ payload = _build_result_payload (
1327+ cfg ,
1328+ actionable ,
1329+ opened_rows ,
1330+ assigned_rows ,
1331+ failure_rows ,
1332+ deferred_rows ,
1333+ )
12811334 logger .info (
12821335 f"[BrowserAutomation] { cfg .log_tag } completed: "
12831336 f"visible={ len (actionable )} opened={ len (opened_rows )} "
1284- f"assigned={ len (assigned_rows )} failures={ len (failure_rows )} "
1337+ f"assigned={ len (assigned_rows )} failures={ len (failure_rows )} "
1338+ f"deferred={ len (deferred_rows )} "
12851339 )
12861340 # Signal any concurrently-running LLM invocation to stop — it would
12871341 # just duplicate work.
12881342 assigned_sessions = dispatch_state .setdefault ("assigned_sessions" , {})
1289- if assigned_rows or (not failure_rows and assigned_sessions ):
1343+ if not deferred_rows and ( assigned_rows or (not failure_rows and assigned_sessions ) ):
12901344 setattr (session , cfg .flag_attr , True )
12911345 return {
12921346 "final" : json .dumps (payload , ensure_ascii = False ),
1293- "history" : cfg .history_prefix ,
1347+ "history" : (
1348+ f"{ cfg .history_prefix } :deferred:feige_focus_contention"
1349+ if deferred_rows
1350+ else cfg .history_prefix
1351+ ),
12941352 }
0 commit comments