use chrono::Utc;
use std::time::Duration;
use tracing::{info, warn};
use crate::Role;
use crate::Workspace;
use crate::agent::run_agent;
use crate::board::TicketPhase;
use crate::turso;
const MAX_PRE_DEV_TICKETS: i64 = 5;
pub async fn run_maintainer_loop() {
let interval = Duration::from_mins(1);
let shutdown = crate::shutdown::shutdown_token();
loop {
if !crate::shutdown::sleep_or_shutdown(interval).await {
break;
}
let workspaces = match crate::workspace::get_workspaces().await {
Ok(list) => list,
Err(e) => {
warn!(error = %e, "Maintainer: failed to list workspaces");
continue;
}
};
if workspaces.is_empty() {
info!("Maintainer: no workspaces configured, skipping cycle");
continue;
}
for ws in &workspaces {
if shutdown.is_cancelled() {
break;
}
if !ws.maintenance_enabled {
continue;
}
if ws.status != "ready" {
info!(workspace = %ws.name, status = %ws.status, "Maintainer: skipping — workspace not ready");
continue;
}
if should_skip_maintainer_debounce(ws) {
continue;
}
if is_maintainer_pipeline_full(ws).await {
continue;
}
let run_id = crate::session::maintainer_session_key(&ws.name);
info!(workspace = %ws.name, run = %run_id, "Maintainer: starting maintenance run");
let prompt = crate::prompt::load_prompt("maintain.md");
let (agent, response) =
run_agent(run_id.clone(), Role::Maintainer, ws, None, &prompt).await;
if let Some(_response) = response {
info!(workspace = %ws.name, "Maintainer: run complete");
let now_str = turso::now();
let new_debounce =
compute_debounce(&agent.id, ws.maintainer_debounce_mins, ws.name.as_str())
.await;
if let Err(e) = crate::workspace::store()
.set_maintenance_debounce(&ws.name, new_debounce, &now_str)
.await
{
warn!(workspace = %ws.name, error = %e, "Maintainer: failed to update debounce state");
}
} else {
info!(workspace = %ws.name, "Maintainer: run failed or cancelled — debounce unchanged");
}
}
}
}
fn should_skip_maintainer_debounce(ws: &Workspace) -> bool {
let now = Utc::now();
let debounce = ws.maintainer_debounce_mins.clamp(0, 240);
if let Some(ref last_str) = ws.maintainer_last_run_at {
match turso::parse_utc_timestamp(last_str) {
Ok(last_time) => {
let elapsed = now - last_time;
let mins_elapsed = elapsed.num_minutes();
if mins_elapsed < debounce {
return true;
}
}
Err(e) => {
warn!(
maintainer_last_run_at = %last_str,
error = %e,
"Failed to parse maintainer_last_run_at, letting through"
);
}
}
}
false
}
async fn is_maintainer_pipeline_full(ws: &Workspace) -> bool {
let Some(board) = crate::board::BOARD.get() else {
return false;
};
let count_status = |phase: TicketPhase| async move {
match board.count_by_phase(phase, Some(&ws.name)).await {
Ok(c) => c,
Err(e) => {
warn!(workspace = %ws.name, %phase, error = %e, "Maintainer: failed to count tickets");
0
}
}
};
let pre_dev_count = {
let analysis = count_status(TicketPhase::Analysis).await;
let planning = count_status(TicketPhase::Planning).await;
let ready = count_status(TicketPhase::ReadyForDevelopment).await;
analysis + planning + ready
};
if pre_dev_count >= MAX_PRE_DEV_TICKETS {
info!(
workspace = %ws.name,
pre_dev = pre_dev_count,
"Maintainer: skipping — pre-development pipeline has >= {} tickets",
MAX_PRE_DEV_TICKETS,
);
return true;
}
false
}
async fn compute_debounce(agent_id: &str, current: i64, ws_name: &str) -> i64 {
let store = crate::stats::store();
match store.query_tool_usage(agent_id, "create_ticket").await {
Ok(call_count) if call_count > 0 => {
info!(workspace = %ws_name, "Maintainer: produced tickets — reset debounce to 1");
1
}
Ok(_) => {
let new_val = advance_debounce(current);
if new_val >= 240 && current < 240 {
info!(workspace = %ws_name, "Maintainer: no tickets created — debounce capped at 240");
} else {
info!(workspace = %ws_name, "Maintainer: no tickets created — debounce advanced to {new_val}");
}
new_val
}
Err(e) => {
warn!(workspace = %ws_name, error = %e, "Maintainer: stats query failed, advancing debounce");
advance_debounce(current)
}
}
}
fn advance_debounce(mins: i64) -> i64 {
(mins.clamp(5, 240) * 2).min(240)
}
#[cfg(test)]
mod tests {
use super::*;
fn ws_with(last_run_at: Option<&str>, debounce_mins: i64) -> Workspace {
Workspace {
name: "test-ws".into(),
path: "/tmp/test".into(),
status: "ready".into(),
created_at: String::new(),
updated_at: String::new(),
maintenance_enabled: true,
paused: false,
maintainer_debounce_mins: debounce_mins,
maintainer_last_run_at: last_run_at.map(String::from),
diagnostics: None,
diagnostics_updated_at: None,
}
}
#[test]
fn should_skip_maintainer_debounce_cases() {
let now_str = Utc::now().to_rfc3339();
let cases = [
(
ws_with(None, 5),
false,
"no prior run → last_run_at is None → no debounce",
),
(
ws_with(Some("garbage-timestamp"), 5),
false,
"unparseable timestamp → parse error → let through",
),
(
ws_with(Some(&now_str), 240),
true,
"just ran — elapsed ~0s < 240 → skip",
),
(
ws_with(Some("2020-01-01T00:00:00Z"), 5),
false,
"long ago — many years elapsed >= 5 → let through",
),
(
ws_with(Some("2020-01-01T00:00:00Z"), -5),
false,
"debounce clamped from -5 to 0 → mins_elapsed < 0 never true",
),
(
ws_with(Some(&now_str), 500),
true,
"debounce clamped from 500 to 240 → elapsed ~0s < 240 → skip",
),
];
for (ws, expected, reason) in &cases {
assert_eq!(
should_skip_maintainer_debounce(ws),
*expected,
"case: {reason}"
);
}
}
#[test]
fn advance_debounce_edges() {
assert_eq!(advance_debounce(0), 10);
assert_eq!(advance_debounce(4), 10);
assert_eq!(advance_debounce(5), 10);
assert_eq!(advance_debounce(6), 12);
assert_eq!(advance_debounce(60), 120);
assert_eq!(advance_debounce(119), 238);
assert_eq!(advance_debounce(120), 240);
assert_eq!(advance_debounce(121), 240);
assert_eq!(advance_debounce(240), 240);
assert_eq!(advance_debounce(300), 240);
}
}