1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
//! Maintainer — autonomous periodic codebase investigation agent.
//!
//! The Maintainer scans workspaces for refactoring opportunities and creates
//! planning tickets on the board. It does NOT make direct code changes.
use chrono::Utc;
use std::time::Duration;
use tracing::{info, warn};
use crate::Role;
use crate::agent::run_agent;
use crate::board::TicketPhase;
use crate::turso;
/// Run the maintainer background loop.
///
/// Runs a Maintainer agent per workspace with the investigation prompt.
/// On success (agent produced a response), updates `maintainer_last_run_at`
/// and adjusts debounce: resets to 1 min if tickets were created, doubles
/// (capped at 240 min) otherwise. On cancellation or error, debounce and
/// last-run timestamp are left unchanged.
#[allow(clippy::too_many_lines)]
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;
}
// Fetch all workspaces
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;
}
// Per-workspace pause — skip maintainer for this workspace.
if ws.paused {
continue;
}
// Skip workspace if maintainer is not explicitly enabled
if !ws.maintenance {
continue;
}
// Only maintain workspaces whose discovery has completed.
if ws.status != "ready" {
info!(workspace = %ws.name, status = %ws.status, "Maintainer: skipping — workspace not ready");
continue;
}
// ── Debounce gate check ──────────────────────────────────────
{
let now = Utc::now();
let debounce = ws.maintainer_debounce_mins.clamp(0, 240); // floor+cap guard
if let Some(ref last_str) = ws.maintainer_last_run_at {
match crate::turso::parse_utc_timestamp(last_str) {
Ok(last_time) => {
let elapsed = now - last_time;
let mins_elapsed = elapsed.num_minutes();
if mins_elapsed < debounce {
continue;
}
}
Err(e) => {
warn!(maintainer_last_run_at = %last_str, error = %e, "Failed to parse maintainer_last_run_at, letting through");
// If parse fails, let it through — stale data shouldn't block
}
}
}
// If last_run_at is None, always run (first time).
}
// Skip if there are already enough planning or paused tickets — pipeline pressure valve
// Skip if this workspace has active pipeline tickets (dev/review/QA)
if let Some(board) = crate::board::BOARD.get() {
let count_status = |status: TicketPhase| async move {
match board.count_by_status(status, Some(&ws.name)).await {
Ok(c) => c,
Err(e) => {
warn!(workspace = %ws.name, %status, error = %e, "Maintainer: failed to count tickets");
0
}
}
};
let c1 = count_status(TicketPhase::Planning).await;
let c2 = count_status(TicketPhase::Paused).await;
if c1 + c2 >= 5 {
info!(workspace = %ws.name, planning = c1, paused = c2, "Maintainer: skipping — tickets already in planning or paused");
continue;
}
match board.has_pipeline_blocker_for_workspace(&ws.name).await {
Ok(true) => {
info!(workspace = %ws.name, "Maintainer: skipping — workspace has active pipeline tickets");
continue;
}
Ok(false) => {}
Err(e) => {
warn!(workspace = %ws.name, error = %e, "Maintainer: failed to check pipeline blocker");
continue;
}
}
}
// Unique session key per run — don't accumulate history
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");
// ── Debounce update after successful run ──────────────────
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 {
// Error or cancellation — do NOT advance debounce, do NOT update last_run_at
info!(workspace = %ws.name, "Maintainer: run failed or cancelled — debounce unchanged");
}
// Backlog tickets are discovered by the poll loop (BacklogAnalysis),
// not via explicit notification — no Manager notification needed here.
}
}
}
/// Compute the new debounce value based on whether the agent produced tickets.
///
/// - If `create_ticket` was called → reset to 1.
/// - If no `create_ticket` calls → double (clamped to `[5, 240]`, capped at 240).
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(Some(call_count)) if call_count > 0 => {
info!(workspace = %ws_name, "Maintainer: produced tickets — reset debounce to 1");
1
}
Ok(Some(_) | None) => {
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 doubled to {new_val}");
}
new_val
}
Err(e) => {
warn!(workspace = %ws_name, error = %e, "Maintainer: stats query failed, advancing debounce");
advance_debounce(current)
}
}
}
/// Double the debounce value, clamped to `[5, 240]` with a hard cap at 240.
fn advance_debounce(mins: i64) -> i64 {
(mins.clamp(5, 240) * 2).min(240)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn advance_debounce_edges() {
// Floor: values below 5 clamp to 5, then double to 10.
assert_eq!(advance_debounce(0), 10);
assert_eq!(advance_debounce(4), 10);
assert_eq!(advance_debounce(5), 10);
// Normal doubling.
assert_eq!(advance_debounce(6), 12);
assert_eq!(advance_debounce(60), 120);
// Cap: 120 doubles to 240, 121 clamps→240, 240 stays at 240.
assert_eq!(advance_debounce(119), 238);
assert_eq!(advance_debounce(120), 240);
assert_eq!(advance_debounce(121), 240);
assert_eq!(advance_debounce(240), 240);
// Above 240 — clamp brings it down, then double hits cap.
assert_eq!(advance_debounce(300), 240);
}
}