@@ -109,6 +109,39 @@ impl super::Db {
109109 ) ?)
110110 }
111111
112+ /// Count "fruitless" tasks for a given Linear issue and pipeline stage (RIG-405).
113+ ///
114+ /// A fruitless task is one that made no progress:
115+ /// - `status = 'failed'` (crashed or timed out), OR
116+ /// - `status = 'completed'` but produced **no effects** in the effects table
117+ /// (e.g. Qwen agent completes with empty output — no MoveIssue, CreatePr, etc.)
118+ ///
119+ /// Used by `should_skip_due_to_failures()` to detect infinite spawn loops where
120+ /// agents complete successfully but make zero progress. Pure `count_failed_tasks`
121+ /// misses this case because the task status is "completed", not "failed".
122+ pub fn count_fruitless_tasks_for_issue_stage (
123+ & self ,
124+ issue_id : & str ,
125+ stage : & str ,
126+ ) -> Result < i64 > {
127+ Ok ( self . conn . query_row (
128+ "SELECT COUNT(*) FROM tasks t
129+ WHERE t.issue_identifier = ?1
130+ AND t.pipeline_stage = ?2
131+ AND (
132+ t.status = 'failed'
133+ OR (
134+ t.status = 'completed'
135+ AND NOT EXISTS (
136+ SELECT 1 FROM effects e WHERE e.task_id = t.id
137+ )
138+ )
139+ )" ,
140+ params ! [ issue_id, stage] ,
141+ |row| row. get ( 0 ) ,
142+ ) ?)
143+ }
144+
112145 /// Count all attempts (completed + failed) for a given Linear issue and pipeline stage.
113146 /// Used as a general circuit breaker (RIG-309). Retry cap (RIG-338) uses
114147 /// `count_failed_tasks_for_issue_stage` instead to avoid capping successful verdicts.
@@ -362,6 +395,41 @@ impl super::Db {
362395 Ok ( result)
363396 }
364397
398+ /// Get the `finished_at` timestamp of the most recent fruitless task for an issue+stage.
399+ /// A fruitless task is one that failed OR completed without producing any effects.
400+ /// Returns `None` if no fruitless tasks exist or if `finished_at` is NULL.
401+ /// Used by the poller cooldown (RIG-405) — mirrors `count_fruitless_tasks_for_issue_stage`
402+ /// but returns the latest timestamp instead of a count.
403+ pub fn last_fruitless_task_time_for_issue_stage (
404+ & self ,
405+ issue_id : & str ,
406+ stage : & str ,
407+ ) -> Result < Option < String > > {
408+ let result: Option < String > = self
409+ . conn
410+ . query_row (
411+ "SELECT finished_at FROM tasks t
412+ WHERE t.issue_identifier = ?1
413+ AND t.pipeline_stage = ?2
414+ AND (
415+ t.status = 'failed'
416+ OR (
417+ t.status = 'completed'
418+ AND NOT EXISTS (
419+ SELECT 1 FROM effects e WHERE e.task_id = t.id
420+ )
421+ )
422+ )
423+ AND t.finished_at IS NOT NULL
424+ ORDER BY t.finished_at DESC
425+ LIMIT 1" ,
426+ params ! [ issue_id, stage] ,
427+ |row| row. get ( 0 ) ,
428+ )
429+ . ok ( ) ;
430+ Ok ( result)
431+ }
432+
365433 // --- PR Reviewed ---
366434
367435 pub fn is_pr_reviewed ( & self , pr_key : & str ) -> Result < bool > {
@@ -1286,4 +1354,263 @@ mod tests {
12861354 . unwrap ( ) ;
12871355 assert_eq ! ( result, Some ( "2026-04-01T10:00:00" . to_string( ) ) ) ;
12881356 }
1357+
1358+ // ─── RIG-405: last_fruitless_task_time_for_issue_stage ────────────────
1359+
1360+ #[ test]
1361+ fn last_fruitless_time_none_when_no_tasks ( ) {
1362+ let db = Db :: open_in_memory ( ) . unwrap ( ) ;
1363+ let result = db
1364+ . last_fruitless_task_time_for_issue_stage ( "RIG-405" , "engineer" )
1365+ . unwrap ( ) ;
1366+ assert ! ( result. is_none( ) ) ;
1367+ }
1368+
1369+ #[ test]
1370+ fn last_fruitless_time_returns_failed_task ( ) {
1371+ let db = Db :: open_in_memory ( ) . unwrap ( ) ;
1372+
1373+ let mut t = make_test_task ( "20260409-010" ) ;
1374+ t. issue_identifier = "RIG-405" . to_string ( ) ;
1375+ t. pipeline_stage = "engineer" . to_string ( ) ;
1376+ db. insert_task ( & t) . unwrap ( ) ;
1377+ db. set_task_status ( "20260409-010" , Status :: Failed ) . unwrap ( ) ;
1378+ db. update_task_field ( "20260409-010" , "finished_at" , "2026-04-09T10:00:00" )
1379+ . unwrap ( ) ;
1380+
1381+ let result = db
1382+ . last_fruitless_task_time_for_issue_stage ( "RIG-405" , "engineer" )
1383+ . unwrap ( ) ;
1384+ assert_eq ! ( result, Some ( "2026-04-09T10:00:00" . to_string( ) ) ) ;
1385+ }
1386+
1387+ #[ test]
1388+ fn last_fruitless_time_returns_completed_without_effects ( ) {
1389+ let db = Db :: open_in_memory ( ) . unwrap ( ) ;
1390+
1391+ let mut t = make_test_task ( "20260409-011" ) ;
1392+ t. issue_identifier = "RIG-405" . to_string ( ) ;
1393+ t. pipeline_stage = "engineer" . to_string ( ) ;
1394+ db. insert_task ( & t) . unwrap ( ) ;
1395+ db. set_task_status ( "20260409-011" , Status :: Completed )
1396+ . unwrap ( ) ;
1397+ db. update_task_field ( "20260409-011" , "finished_at" , "2026-04-09T11:00:00" )
1398+ . unwrap ( ) ;
1399+
1400+ let result = db
1401+ . last_fruitless_task_time_for_issue_stage ( "RIG-405" , "engineer" )
1402+ . unwrap ( ) ;
1403+ assert_eq ! (
1404+ result,
1405+ Some ( "2026-04-09T11:00:00" . to_string( ) ) ,
1406+ "completed task with no effects should be fruitless"
1407+ ) ;
1408+ }
1409+
1410+ #[ test]
1411+ fn last_fruitless_time_ignores_completed_with_effects ( ) {
1412+ use crate :: models:: { Effect , EffectStatus , EffectType } ;
1413+ let db = Db :: open_in_memory ( ) . unwrap ( ) ;
1414+
1415+ let mut t = make_test_task ( "20260409-012" ) ;
1416+ t. issue_identifier = "RIG-405" . to_string ( ) ;
1417+ t. pipeline_stage = "engineer" . to_string ( ) ;
1418+ db. insert_task ( & t) . unwrap ( ) ;
1419+ db. set_task_status ( "20260409-012" , Status :: Completed )
1420+ . unwrap ( ) ;
1421+ db. update_task_field ( "20260409-012" , "finished_at" , "2026-04-09T12:00:00" )
1422+ . unwrap ( ) ;
1423+
1424+ let effect = Effect {
1425+ id : 0 ,
1426+ dedup_key : "20260409-012:create-pr" . to_string ( ) ,
1427+ task_id : "20260409-012" . to_string ( ) ,
1428+ issue_id : "RIG-405" . to_string ( ) ,
1429+ effect_type : EffectType :: CreatePr ,
1430+ payload : serde_json:: json!( { } ) ,
1431+ blocking : true ,
1432+ status : EffectStatus :: Pending ,
1433+ attempts : 0 ,
1434+ max_attempts : 3 ,
1435+ created_at : "2026-04-09T12:00:00" . to_string ( ) ,
1436+ next_retry_at : None ,
1437+ executed_at : None ,
1438+ error : None ,
1439+ } ;
1440+ db. insert_effects ( & [ effect] ) . unwrap ( ) ;
1441+
1442+ let result = db
1443+ . last_fruitless_task_time_for_issue_stage ( "RIG-405" , "engineer" )
1444+ . unwrap ( ) ;
1445+ assert ! (
1446+ result. is_none( ) ,
1447+ "completed task with effects must NOT be fruitless"
1448+ ) ;
1449+ }
1450+
1451+ #[ test]
1452+ fn last_fruitless_time_returns_most_recent ( ) {
1453+ let db = Db :: open_in_memory ( ) . unwrap ( ) ;
1454+
1455+ // Failed task at 10:00
1456+ let mut t1 = make_test_task ( "20260409-013" ) ;
1457+ t1. issue_identifier = "RIG-405" . to_string ( ) ;
1458+ t1. pipeline_stage = "engineer" . to_string ( ) ;
1459+ db. insert_task ( & t1) . unwrap ( ) ;
1460+ db. set_task_status ( "20260409-013" , Status :: Failed ) . unwrap ( ) ;
1461+ db. update_task_field ( "20260409-013" , "finished_at" , "2026-04-09T10:00:00" )
1462+ . unwrap ( ) ;
1463+
1464+ // Fruitless completion at 11:00
1465+ let mut t2 = make_test_task ( "20260409-014" ) ;
1466+ t2. issue_identifier = "RIG-405" . to_string ( ) ;
1467+ t2. pipeline_stage = "engineer" . to_string ( ) ;
1468+ db. insert_task ( & t2) . unwrap ( ) ;
1469+ db. set_task_status ( "20260409-014" , Status :: Completed )
1470+ . unwrap ( ) ;
1471+ db. update_task_field ( "20260409-014" , "finished_at" , "2026-04-09T11:00:00" )
1472+ . unwrap ( ) ;
1473+
1474+ let result = db
1475+ . last_fruitless_task_time_for_issue_stage ( "RIG-405" , "engineer" )
1476+ . unwrap ( ) ;
1477+ assert_eq ! (
1478+ result,
1479+ Some ( "2026-04-09T11:00:00" . to_string( ) ) ,
1480+ "should return the most recent fruitless task time"
1481+ ) ;
1482+ }
1483+
1484+ // ─── RIG-405: count_fruitless_tasks_for_issue_stage ─────────────────
1485+
1486+ #[ test]
1487+ fn fruitless_count_zero_when_no_tasks ( ) {
1488+ let db = Db :: open_in_memory ( ) . unwrap ( ) ;
1489+ let count = db
1490+ . count_fruitless_tasks_for_issue_stage ( "RIG-405" , "engineer" )
1491+ . unwrap ( ) ;
1492+ assert_eq ! ( count, 0 ) ;
1493+ }
1494+
1495+ #[ test]
1496+ fn fruitless_count_includes_failed_tasks ( ) {
1497+ let db = Db :: open_in_memory ( ) . unwrap ( ) ;
1498+
1499+ let mut t = make_test_task ( "20260409-001" ) ;
1500+ t. issue_identifier = "RIG-405" . to_string ( ) ;
1501+ t. pipeline_stage = "engineer" . to_string ( ) ;
1502+ db. insert_task ( & t) . unwrap ( ) ;
1503+ db. set_task_status ( "20260409-001" , Status :: Failed ) . unwrap ( ) ;
1504+
1505+ let count = db
1506+ . count_fruitless_tasks_for_issue_stage ( "RIG-405" , "engineer" )
1507+ . unwrap ( ) ;
1508+ assert_eq ! ( count, 1 , "failed task must count as fruitless" ) ;
1509+ }
1510+
1511+ #[ test]
1512+ fn fruitless_count_includes_completed_without_effects ( ) {
1513+ // Simulates Qwen completing with empty output (no effects produced).
1514+ let db = Db :: open_in_memory ( ) . unwrap ( ) ;
1515+
1516+ let mut t = make_test_task ( "20260409-002" ) ;
1517+ t. issue_identifier = "RIG-405" . to_string ( ) ;
1518+ t. pipeline_stage = "engineer" . to_string ( ) ;
1519+ db. insert_task ( & t) . unwrap ( ) ;
1520+ db. set_task_status ( "20260409-002" , Status :: Completed )
1521+ . unwrap ( ) ;
1522+
1523+ let count = db
1524+ . count_fruitless_tasks_for_issue_stage ( "RIG-405" , "engineer" )
1525+ . unwrap ( ) ;
1526+ assert_eq ! (
1527+ count, 1 ,
1528+ "completed task with no effects must count as fruitless"
1529+ ) ;
1530+ }
1531+
1532+ #[ test]
1533+ fn fruitless_count_excludes_completed_with_effects ( ) {
1534+ // A completed task that produced at least one effect is NOT fruitless.
1535+ use crate :: models:: { Effect , EffectStatus , EffectType } ;
1536+ let db = Db :: open_in_memory ( ) . unwrap ( ) ;
1537+
1538+ let mut t = make_test_task ( "20260409-003" ) ;
1539+ t. issue_identifier = "RIG-405" . to_string ( ) ;
1540+ t. pipeline_stage = "engineer" . to_string ( ) ;
1541+ db. insert_task ( & t) . unwrap ( ) ;
1542+ db. set_task_status ( "20260409-003" , Status :: Completed )
1543+ . unwrap ( ) ;
1544+
1545+ // Insert an effect for this task (e.g. engineer created a PR)
1546+ let effect = Effect {
1547+ id : 0 ,
1548+ dedup_key : "20260409-003:create-pr" . to_string ( ) ,
1549+ task_id : "20260409-003" . to_string ( ) ,
1550+ issue_id : "RIG-405" . to_string ( ) ,
1551+ effect_type : EffectType :: CreatePr ,
1552+ payload : serde_json:: json!( { } ) ,
1553+ blocking : true ,
1554+ status : EffectStatus :: Pending ,
1555+ attempts : 0 ,
1556+ max_attempts : 3 ,
1557+ created_at : "2026-04-09T10:00:00" . to_string ( ) ,
1558+ next_retry_at : None ,
1559+ executed_at : None ,
1560+ error : None ,
1561+ } ;
1562+ db. insert_effects ( & [ effect] ) . unwrap ( ) ;
1563+
1564+ let count = db
1565+ . count_fruitless_tasks_for_issue_stage ( "RIG-405" , "engineer" )
1566+ . unwrap ( ) ;
1567+ assert_eq ! (
1568+ count, 0 ,
1569+ "completed task with effects must NOT count as fruitless"
1570+ ) ;
1571+ }
1572+
1573+ #[ test]
1574+ fn fruitless_count_filters_by_stage ( ) {
1575+ let db = Db :: open_in_memory ( ) . unwrap ( ) ;
1576+
1577+ // Failed task for "reviewer" stage
1578+ let mut t = make_test_task ( "20260409-004" ) ;
1579+ t. issue_identifier = "RIG-405" . to_string ( ) ;
1580+ t. pipeline_stage = "reviewer" . to_string ( ) ;
1581+ db. insert_task ( & t) . unwrap ( ) ;
1582+ db. set_task_status ( "20260409-004" , Status :: Failed ) . unwrap ( ) ;
1583+
1584+ // Engineer stage should see 0
1585+ let count = db
1586+ . count_fruitless_tasks_for_issue_stage ( "RIG-405" , "engineer" )
1587+ . unwrap ( ) ;
1588+ assert_eq ! ( count, 0 , "must filter by stage name" ) ;
1589+ }
1590+
1591+ #[ test]
1592+ fn fruitless_count_excludes_running_and_pending ( ) {
1593+ let db = Db :: open_in_memory ( ) . unwrap ( ) ;
1594+
1595+ // Pending task
1596+ let mut t1 = make_test_task ( "20260409-005" ) ;
1597+ t1. issue_identifier = "RIG-405" . to_string ( ) ;
1598+ t1. pipeline_stage = "engineer" . to_string ( ) ;
1599+ db. insert_task ( & t1) . unwrap ( ) ;
1600+
1601+ // Running task
1602+ let mut t2 = make_test_task ( "20260409-006" ) ;
1603+ t2. issue_identifier = "RIG-405" . to_string ( ) ;
1604+ t2. pipeline_stage = "engineer" . to_string ( ) ;
1605+ db. insert_task ( & t2) . unwrap ( ) ;
1606+ db. set_task_status ( "20260409-006" , Status :: Running ) . unwrap ( ) ;
1607+
1608+ let count = db
1609+ . count_fruitless_tasks_for_issue_stage ( "RIG-405" , "engineer" )
1610+ . unwrap ( ) ;
1611+ assert_eq ! (
1612+ count, 0 ,
1613+ "pending/running tasks must not count as fruitless"
1614+ ) ;
1615+ }
12891616}
0 commit comments