@@ -91,6 +91,14 @@ const DEFAULT_SWARM_TASK_STALE_AFTER_SECS: u64 = 45;
9191const DEFAULT_SWARM_TASK_SWEEP_INTERVAL_SECS : u64 = 5 ;
9292const DEFAULT_SWARM_TERMINAL_MEMBER_RETENTION_SECS : u64 = 24 * 60 * 60 ;
9393const DEFAULT_SWARM_TERMINAL_MEMBER_GC_INTERVAL_SECS : u64 = 60 ;
94+ /// How long terminal members stay in live SwarmStatus broadcasts. Terminal
95+ /// members remain queryable for the full retention window above, but
96+ /// re-sending hundreds of long-finished members to every attached client on
97+ /// every status change dominates broadcast payloads (measured ~240 KB of
98+ /// member JSON resident per client with ~700 mostly-stopped members). Keep
99+ /// them in broadcasts briefly so done/failed transition notices still fire,
100+ /// then drop them from the live fan-out.
101+ const DEFAULT_SWARM_STATUS_BROADCAST_TERMINAL_SECS : u64 = 15 * 60 ;
94102#[ derive( Default , Clone , Copy ) ]
95103struct PendingSwarmStatusBroadcast {
96104 scheduled : bool ,
@@ -197,6 +205,22 @@ pub(super) fn swarm_terminal_member_gc_interval() -> Duration {
197205 ) )
198206}
199207
208+ /// How long terminal members remain included in live SwarmStatus broadcasts.
209+ /// See [`DEFAULT_SWARM_STATUS_BROADCAST_TERMINAL_SECS`].
210+ pub ( super ) fn swarm_status_broadcast_terminal_retention ( ) -> Duration {
211+ Duration :: from_secs ( configured_positive_u64 (
212+ "JCODE_SWARM_STATUS_BROADCAST_TERMINAL_SECS" ,
213+ DEFAULT_SWARM_STATUS_BROADCAST_TERMINAL_SECS ,
214+ ) )
215+ }
216+
217+ /// Whether a member belongs in live SwarmStatus broadcasts: every live member,
218+ /// plus terminal members whose status changed recently enough that clients may
219+ /// still want to announce or display the transition.
220+ pub ( super ) fn member_in_status_broadcast ( member : & SwarmMember , retention : Duration ) -> bool {
221+ !member_status_is_terminal ( & member. status ) || member. last_status_change . elapsed ( ) < retention
222+ }
223+
200224/// Terminal members are historical records, not live agents. They remain
201225/// visible temporarily for reports and diagnostics but must not consume the
202226/// runaway-prevention spawn budget.
@@ -631,11 +655,13 @@ async fn broadcast_swarm_status_now(
631655 }
632656
633657 let members_guard = swarm_members. read ( ) . await ;
658+ let broadcast_terminal_retention = swarm_status_broadcast_terminal_retention ( ) ;
634659 let members_list: Vec < crate :: protocol:: SwarmMemberStatus > = session_ids
635660 . iter ( )
636661 . filter_map ( |sid| {
637662 members_guard
638663 . get ( sid)
664+ . filter ( |m| member_in_status_broadcast ( m, broadcast_terminal_retention) )
639665 . map ( |m| crate :: protocol:: SwarmMemberStatus {
640666 session_id : m. session_id . clone ( ) ,
641667 friendly_name : m. friendly_name . clone ( ) ,
@@ -1672,10 +1698,11 @@ fn parse_swarm_tasks(text: &str) -> Vec<SwarmTaskSpec> {
16721698mod tests {
16731699 use super :: {
16741700 broadcast_swarm_plan, broadcast_swarm_plan_with_previous, broadcast_swarm_status,
1675- member_status_is_dead, now_unix_ms, parse_swarm_tasks, refresh_swarm_task_staleness,
1676- remove_session_from_swarm, salvage_assignments_of_dead_member, swarm_ancestors,
1677- swarm_is_self_or_ancestor, swarm_spawn_depth, touch_swarm_task_progress,
1678- update_member_status, update_member_status_with_report,
1701+ member_in_status_broadcast, member_status_is_dead, now_unix_ms, parse_swarm_tasks,
1702+ refresh_swarm_task_staleness, remove_session_from_swarm,
1703+ salvage_assignments_of_dead_member, swarm_ancestors, swarm_is_self_or_ancestor,
1704+ swarm_spawn_depth, touch_swarm_task_progress, update_member_status,
1705+ update_member_status_with_report,
16791706 } ;
16801707 use crate :: plan:: PlanItem ;
16811708 use crate :: protocol:: { NotificationType , ServerEvent } ;
@@ -1785,6 +1812,28 @@ mod tests {
17851812 member
17861813 }
17871814
1815+ #[ test]
1816+ fn status_broadcast_keeps_live_and_recently_terminal_members_only ( ) {
1817+ let retention = Duration :: from_secs ( 900 ) ;
1818+
1819+ let ( live, _rx) = swarm_member ( "live" , "agent" , false ) ;
1820+ assert ! ( member_in_status_broadcast( & live, retention) ) ;
1821+
1822+ let ( mut fresh_terminal, _rx) = swarm_member ( "fresh" , "agent" , false ) ;
1823+ fresh_terminal. status = "completed" . to_string ( ) ;
1824+ assert ! ( member_in_status_broadcast( & fresh_terminal, retention) ) ;
1825+
1826+ let ( mut stale_terminal, _rx) = swarm_member ( "stale" , "agent" , false ) ;
1827+ stale_terminal. status = "stopped" . to_string ( ) ;
1828+ stale_terminal. last_status_change = Instant :: now ( ) - Duration :: from_secs ( 901 ) ;
1829+ assert ! ( !member_in_status_broadcast( & stale_terminal, retention) ) ;
1830+
1831+ // A stale *live* status is never filtered, no matter how old.
1832+ let ( mut old_live, _rx) = swarm_member ( "old-live" , "agent" , false ) ;
1833+ old_live. last_status_change = Instant :: now ( ) - Duration :: from_secs ( 100_000 ) ;
1834+ assert ! ( member_in_status_broadcast( & old_live, retention) ) ;
1835+ }
1836+
17881837 #[ test]
17891838 fn swarm_depth_and_ancestry_follow_report_back_chain ( ) {
17901839 let mut members: HashMap < String , SwarmMember > = HashMap :: new ( ) ;
0 commit comments