Skip to content

Commit be94449

Browse files
authored
RIG-405 feat: implementation (#240)
## Summary Pipeline engineer task `20260409-002`. Linear: RIG-405
1 parent 496902a commit be94449

File tree

2 files changed

+556
-109
lines changed

2 files changed

+556
-109
lines changed

engine/src/db/pipeline.rs

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)