@@ -251,40 +251,46 @@ pub(crate) async fn reconcile_task_projection(
251251 thread_store : & Arc < dyn ThreadStore > ,
252252 garyx_db : & GaryxDbService ,
253253) -> usize {
254- let thread_ids = thread_store. list_keys ( Some ( "thread::" ) ) . await ;
255- let mut projected_thread_ids = BTreeSet :: new ( ) ;
256- let mut reconciled = 0usize ;
257- for thread_id in thread_ids {
258- let Some ( data) = thread_store. get ( & thread_id) . await else {
259- continue ;
260- } ;
261- if let Some ( draft) = task_projection_draft_from_thread_data ( & thread_id, & data) {
262- projected_thread_ids. insert ( thread_id. clone ( ) ) ;
263- if let Err ( error) = garyx_db. replace_task_projection ( draft) {
264- warn ! ( thread_id, error = %error, "failed to reconcile task projection row" ) ;
265- } else {
266- reconciled += 1 ;
267- }
268- }
269- }
270-
271254 let existing_thread_ids = match garyx_db. list_task_projection_thread_ids ( ) {
272255 Ok ( thread_ids) => thread_ids,
273256 Err ( error) => {
274- warn ! ( error = %error, "failed to list task projection rows before reconcile prune " ) ;
275- return reconciled ;
257+ warn ! ( error = %error, "failed to list task projection rows before reconcile" ) ;
258+ return 0 ;
276259 }
277260 } ;
278- for thread_id in existing_thread_ids {
279- if projected_thread_ids. contains ( & thread_id) {
280- continue ;
261+
262+ let mut candidate_thread_ids = existing_thread_ids. into_iter ( ) . collect :: < BTreeSet < _ > > ( ) ;
263+ match garyx_db. list_recent_threads ( usize:: MAX , 0 ) {
264+ Ok ( records) => {
265+ for record in records {
266+ let projection_is_active = record
267+ . active_run_id
268+ . as_deref ( )
269+ . map ( str:: trim)
270+ . is_some_and ( |value| !value. is_empty ( ) )
271+ || record. run_state == "running" ;
272+ if projection_is_active {
273+ candidate_thread_ids. insert ( record. thread_id ) ;
274+ }
275+ }
281276 }
282- let still_has_task = thread_store
277+ Err ( error) => {
278+ warn ! ( error = %error, "failed to list recent thread projection rows before task projection reconcile" ) ;
279+ }
280+ }
281+
282+ let mut reconciled = 0usize ;
283+ for thread_id in candidate_thread_ids {
284+ let draft = thread_store
283285 . get ( & thread_id)
284286 . await
285- . and_then ( |data| task_projection_draft_from_thread_data ( & thread_id, & data) )
286- . is_some ( ) ;
287- if still_has_task {
287+ . and_then ( |data| task_projection_draft_from_thread_data ( & thread_id, & data) ) ;
288+ if let Some ( draft) = draft {
289+ if let Err ( error) = garyx_db. replace_task_projection ( draft) {
290+ warn ! ( thread_id, error = %error, "failed to reconcile task projection row" ) ;
291+ } else {
292+ reconciled += 1 ;
293+ }
288294 continue ;
289295 }
290296 match garyx_db. remove_task_projection ( & thread_id) {
@@ -321,3 +327,155 @@ fn source_channel_account_id(source: &TaskSource) -> Option<String> {
321327 let account_id = normalized ( source. account_id . as_deref ( ) ) ?;
322328 Some ( format ! ( "{channel}:{account_id}" ) )
323329}
330+
331+ #[ cfg( test) ]
332+ mod tests {
333+ use std:: sync:: atomic:: { AtomicUsize , Ordering } ;
334+
335+ use async_trait:: async_trait;
336+ use chrono:: { DateTime , Utc } ;
337+ use garyx_models:: {
338+ Principal , TASK_SCHEMA_VERSION_V1 , TaskEvent , TaskEventKind , TaskStatus , ThreadTask ,
339+ } ;
340+ use garyx_router:: { InMemoryThreadStore , ThreadStore , ThreadStoreError } ;
341+ use serde_json:: { Value , json} ;
342+
343+ use super :: * ;
344+ use crate :: garyx_db:: RecentThreadDraft ;
345+
346+ struct CountingThreadStore {
347+ inner : Arc < InMemoryThreadStore > ,
348+ list_keys_calls : AtomicUsize ,
349+ }
350+
351+ impl CountingThreadStore {
352+ fn new ( ) -> Self {
353+ Self {
354+ inner : Arc :: new ( InMemoryThreadStore :: new ( ) ) ,
355+ list_keys_calls : AtomicUsize :: new ( 0 ) ,
356+ }
357+ }
358+
359+ async fn insert_task ( & self , thread_id : & str , task : ThreadTask ) {
360+ self . inner
361+ . set (
362+ thread_id,
363+ json ! ( { "task" : task, "updated_at" : task. updated_at } ) ,
364+ )
365+ . await ;
366+ }
367+ }
368+
369+ #[ async_trait]
370+ impl ThreadStore for CountingThreadStore {
371+ async fn get ( & self , thread_id : & str ) -> Option < Value > {
372+ self . inner . get ( thread_id) . await
373+ }
374+
375+ async fn set ( & self , thread_id : & str , data : Value ) {
376+ self . inner . set ( thread_id, data) . await ;
377+ }
378+
379+ async fn delete ( & self , thread_id : & str ) -> bool {
380+ self . inner . delete ( thread_id) . await
381+ }
382+
383+ async fn list_keys ( & self , prefix : Option < & str > ) -> Vec < String > {
384+ self . list_keys_calls . fetch_add ( 1 , Ordering :: SeqCst ) ;
385+ self . inner . list_keys ( prefix) . await
386+ }
387+
388+ async fn exists ( & self , thread_id : & str ) -> bool {
389+ self . inner . exists ( thread_id) . await
390+ }
391+
392+ async fn update ( & self , thread_id : & str , updates : Value ) -> Result < ( ) , ThreadStoreError > {
393+ self . inner . update ( thread_id, updates) . await
394+ }
395+ }
396+
397+ fn test_task ( number : u64 , status : TaskStatus , updated_at : & str ) -> ThreadTask {
398+ let at = DateTime :: parse_from_rfc3339 ( updated_at)
399+ . expect ( "valid timestamp" )
400+ . with_timezone ( & Utc ) ;
401+ let actor = Principal :: Agent {
402+ agent_id : "agent:test" . to_owned ( ) ,
403+ } ;
404+ ThreadTask {
405+ schema_version : TASK_SCHEMA_VERSION_V1 ,
406+ number,
407+ title : format ! ( "Task {number}" ) ,
408+ status,
409+ creator : actor. clone ( ) ,
410+ assignee : None ,
411+ notification_target : None ,
412+ source : None ,
413+ executor : None ,
414+ body : None ,
415+ created_at : at,
416+ updated_at : at,
417+ updated_by : actor. clone ( ) ,
418+ events : vec ! [ TaskEvent {
419+ event_id: format!( "event-{number}" ) ,
420+ at,
421+ actor: actor. clone( ) ,
422+ kind: TaskEventKind :: Created {
423+ initial_status: status,
424+ assignee: None ,
425+ } ,
426+ } ] ,
427+ }
428+ }
429+
430+ fn active_recent_thread ( thread_id : & str ) -> RecentThreadDraft {
431+ RecentThreadDraft {
432+ thread_id : thread_id. to_owned ( ) ,
433+ title : "Active task" . to_owned ( ) ,
434+ workspace_dir : None ,
435+ thread_type : "thread" . to_owned ( ) ,
436+ provider_type : Some ( "claude_code" . to_owned ( ) ) ,
437+ agent_id : Some ( "claude" . to_owned ( ) ) ,
438+ message_count : 0 ,
439+ last_message_preview : String :: new ( ) ,
440+ recent_run_id : Some ( "run-active" . to_owned ( ) ) ,
441+ active_run_id : Some ( "run-active" . to_owned ( ) ) ,
442+ run_state : "running" . to_owned ( ) ,
443+ updated_at : Some ( "2026-01-01T00:00:01.000Z" . to_owned ( ) ) ,
444+ last_active_at : "2026-01-01T00:00:01.000Z" . to_owned ( ) ,
445+ }
446+ }
447+
448+ #[ tokio:: test]
449+ async fn task_projection_reconcile_uses_sql_candidates_without_listing_all_threads ( ) {
450+ let store = Arc :: new ( CountingThreadStore :: new ( ) ) ;
451+ let thread_store: Arc < dyn ThreadStore > = store. clone ( ) ;
452+ let db = GaryxDbService :: memory ( ) . expect ( "db opens" ) ;
453+
454+ let active_thread = "thread::active-task" ;
455+ store
456+ . insert_task (
457+ active_thread,
458+ test_task ( 12 , TaskStatus :: InProgress , "2026-01-01T00:00:01.000Z" ) ,
459+ )
460+ . await ;
461+ db. upsert_recent_thread ( active_recent_thread ( active_thread) )
462+ . expect ( "seed active recent row" ) ;
463+
464+ let stale_task = test_task ( 13 , TaskStatus :: Todo , "2026-01-01T00:00:01.000Z" ) ;
465+ db. replace_task_projection (
466+ task_projection_draft_from_task ( "thread::stale-task" , & stale_task)
467+ . expect ( "stale projection draft" ) ,
468+ )
469+ . expect ( "seed stale task projection" ) ;
470+
471+ let reconciled = reconcile_task_projection ( & thread_store, & db) . await ;
472+
473+ assert_eq ! ( reconciled, 2 ) ;
474+ assert_eq ! ( store. list_keys_calls. load( Ordering :: SeqCst ) , 0 ) ;
475+ assert_eq ! (
476+ db. thread_id_for_number( 12 ) . expect( "active lookup" ) ,
477+ Some ( active_thread. to_owned( ) )
478+ ) ;
479+ assert_eq ! ( db. thread_id_for_number( 13 ) . expect( "stale lookup" ) , None ) ;
480+ }
481+ }
0 commit comments