@@ -75,6 +75,8 @@ pub struct HostedSlackRepliesPageV1 {
7575 pub poll_overlap_watermark : String ,
7676 pub root_message_id : String ,
7777 pub root_reply_count : u32 ,
78+ #[ serde( default , skip_serializing_if = "Option::is_none" ) ]
79+ pub reconciliation : Option < HostedSlackRepliesReconciliationV1 > ,
7880 pub request_cursor : Option < String > ,
7981 pub next_cursor : Option < String > ,
8082 pub observed_at : String ,
@@ -83,6 +85,12 @@ pub struct HostedSlackRepliesPageV1 {
8385 pub files : Vec < RawHostedSlackFileMetadata > ,
8486}
8587
88+ #[ derive( Clone , Copy , Debug , PartialEq , Eq , Serialize , Deserialize ) ]
89+ #[ serde( rename_all = "snake_case" ) ]
90+ pub enum HostedSlackRepliesReconciliationV1 {
91+ ThreadNotFound ,
92+ }
93+
8694#[ derive( Clone , Copy , Debug , PartialEq , Eq ) ]
8795pub enum HostedSlackPageApplyOutcomeV1 {
8896 Applied ,
@@ -280,6 +288,20 @@ impl HostedSlackRepliesPageV1 {
280288 ) ) ;
281289 }
282290 }
291+ if self . reconciliation == Some ( HostedSlackRepliesReconciliationV1 :: ThreadNotFound )
292+ && ( self . request_cursor . is_some ( )
293+ || self . next_cursor . is_some ( )
294+ || self . root_reply_count != 0
295+ || self . messages . len ( ) != 1
296+ || self . messages [ 0 ] . ts != self . root_message_id
297+ || !self . messages [ 0 ] . deleted
298+ || !self . messages [ 0 ] . text . is_empty ( )
299+ || !self . messages [ 0 ] . file_ids . is_empty ( ) )
300+ {
301+ return Err ( HostedSlackPollError :: IncompleteCandidate (
302+ "thread-not-found reconciliation" ,
303+ ) ) ;
304+ }
283305 validate_serialized_page_size ( self , "replies page" ) ?;
284306 Ok ( ( ) )
285307 }
@@ -451,11 +473,21 @@ impl HostedSlackPollCheckpointV1 {
451473 return Err ( HostedSlackPollError :: PageWindowMismatch ) ;
452474 }
453475 let first_page = page. request_cursor . is_none ( ) ;
476+ let deleted_root_reconciliation =
477+ page. reconciliation == Some ( HostedSlackRepliesReconciliationV1 :: ThreadNotFound ) ;
454478 let expectation_is_from_this_catch_up = page. phase
455479 != HostedSlackPollPhaseV1 :: CatchUpReplies
456480 || catch_up_history_contains_root ( & next, & page. root_message_id ) ?
457481 || !first_page;
458- if expectation_is_from_this_catch_up {
482+ if deleted_root_reconciliation {
483+ upsert_root_expectation (
484+ & mut next. candidate . root_expectations ,
485+ HostedSlackRootExpectationV1 {
486+ root_message_id : page. root_message_id . clone ( ) ,
487+ expected_reply_count : 0 ,
488+ } ,
489+ ) ;
490+ } else if expectation_is_from_this_catch_up {
459491 let expected = expected_reply_count ( & next, & page. root_message_id ) ?;
460492 if expected != page. root_reply_count {
461493 return Err ( HostedSlackPollError :: ReplyCountMismatch {
@@ -483,6 +515,21 @@ impl HostedSlackPollCheckpointV1 {
483515 for message in & accepted_page. messages {
484516 let message_time = parse_slack_timestamp ( "page.message.ts" , & message. ts ) ?;
485517 if message. ts == page. root_message_id {
518+ if deleted_root_reconciliation {
519+ let root = next
520+ . candidate
521+ . messages
522+ . iter_mut ( )
523+ . find ( |candidate| candidate. ts == message. ts )
524+ . ok_or_else ( || HostedSlackPollError :: MissingRoot ( message. ts . clone ( ) ) ) ?;
525+ if normalized_root_id ( root) . is_some ( ) {
526+ return Err ( HostedSlackPollError :: InvalidMessageRelationship (
527+ message. ts . clone ( ) ,
528+ ) ) ;
529+ }
530+ * root = message. clone ( ) ;
531+ continue ;
532+ }
486533 apply_message_current_state (
487534 & mut next. candidate . messages ,
488535 message. clone ( ) ,
@@ -1014,19 +1061,45 @@ fn prepare_reply_phase(
10141061 . filter ( |message| normalized_root_id ( message) . is_none ( ) )
10151062 . map ( |message| message. ts . clone ( ) )
10161063 . collect :: < Vec < _ > > ( ) ;
1064+ let yielded = checkpoint
1065+ . candidate
1066+ . stage_yielded_reply_root_ids
1067+ . iter ( )
1068+ . cloned ( )
1069+ . collect :: < BTreeSet < _ > > ( ) ;
1070+ checkpoint. completed_roots . clear ( ) ;
1071+ let mut pending = Vec :: new ( ) ;
1072+ for root in roots {
1073+ let exact_zero_from_history = touched. contains ( & root)
1074+ && !yielded. contains ( & root)
1075+ && expected_reply_count ( checkpoint, & root) ? == 0 ;
1076+ if exact_zero_from_history {
1077+ checkpoint
1078+ . candidate
1079+ . messages
1080+ . retain ( |message| normalized_root_id ( message) != Some ( root. as_str ( ) ) ) ;
1081+ checkpoint. completed_roots . push ( HostedSlackCompletedRootV1 {
1082+ root_message_id : root,
1083+ expected_reply_count : 0 ,
1084+ observed_reply_count : 0 ,
1085+ completed_phase : reply_phase,
1086+ } ) ;
1087+ } else {
1088+ pending. push ( root) ;
1089+ }
1090+ }
10171091 let applied_pages = checkpoint
10181092 . evidence
10191093 . iter ( )
10201094 . filter ( |evidence| matches ! ( evidence, HostedSlackPollEvidenceV1 :: AppliedPage { .. } ) )
10211095 . count ( ) ;
1022- let minimum_required_pages = applied_pages. saturating_add ( roots . len ( ) ) ;
1096+ let minimum_required_pages = applied_pages. saturating_add ( pending . len ( ) ) ;
10231097 if minimum_required_pages > MAX_HOSTED_SLACK_APPLIED_PAGES_V1 {
10241098 return Err ( HostedSlackPollError :: CollectionTooLarge (
10251099 "catch-up root sweep" ,
10261100 ) ) ;
10271101 }
1028- checkpoint. completed_roots . clear ( ) ;
1029- roots
1102+ pending
10301103 } ;
10311104 checkpoint
10321105 . completed_roots
0 commit comments