cap get_loose_ends todo output + add ack_todos_until bulk-clear (#2944)

This commit is contained in:
damocles 2026-08-02 14:37:22 +02:00 committed by mara
commit eb570c003d
7 changed files with 167 additions and 9 deletions

View file

@ -202,6 +202,7 @@ fn dispatch(
} => clear_todo(store, &subsystem, key.as_deref(), all),
Request::ListTodos { subsystem } => list_todos(store, subsystem.as_deref()),
Request::MarkTodoDone { id } => mark_todo_done(store, id),
Request::MarkTodosDoneUntil { up_to } => mark_todos_done_until(store, up_to),
Request::StoreReminder {
message,
timing,
@ -417,6 +418,21 @@ fn mark_todo_done(store: &Todos, id: i64) -> Response {
}
}
/// `MarkTodosDoneUntil` handler: bulk-acks every un-acked todo id at or
/// below `up_to` — the escape hatch for a backlog too large to triage
/// one-by-one (see `Todos::mark_done_until`'s doc for why it's needed).
fn mark_todos_done_until(store: &Todos, up_to: i64) -> Response {
match store.mark_done_until(up_to) {
Ok(count) => {
tracing::debug!(up_to, count, "todo bulk mark-done");
Response::Acked {
count: u64::try_from(count).unwrap_or(0),
}
}
Err(e) => err(&e),
}
}
/// `Request::Compact` handler: gate on context usage, then queue the same
/// deferred `compact_pending` flag the operator's `/compact` button sets.
/// Mirrors `hive-agent::web_ui::actions::post_compact` but reachable from

View file

@ -245,6 +245,34 @@ impl Todos {
Ok(n)
}
/// Bulk-ack every un-acked todo with `id <= up_to` in one shot — the
/// same "note the highest id you've seen, clear everything up to it"
/// pattern the message inbox's `ack_until` already gives agents, applied
/// to this store. Exists for the case a per-id triage loop isn't worth
/// it: an agent that hasn't called `get_loose_ends` in a long stretch
/// (or one whose producers pile up faster than it triages) can end up
/// with a backlog large enough that the rendered list itself becomes
/// unwieldy — clearing it in one call is the escape hatch, same
/// `acked`-not-deleted semantics as [`Self::mark_done`] (a reconciled
/// producer's next `upsert` still sees the row to compare against).
/// Returns the number of rows newly acked.
///
/// # Errors
///
/// Propagates the sqlite update failure.
///
/// # Panics
///
/// Panics if the connection mutex is poisoned.
pub fn mark_done_until(&self, up_to: i64) -> Result<usize> {
let conn = self.conn.lock().unwrap();
let n = conn.execute(
"UPDATE todos SET acked = 1, acked_at = ?1 WHERE acked = 0 AND id <= ?2",
params![Utc::now().timestamp(), up_to],
)?;
Ok(n)
}
/// List todos, newest-updated first. `subsystem = Some(..)` filters to
/// one producer's set; `None` returns all. Excludes acked rows — once
/// the agent has dismissed a todo it stays out of its own list, even
@ -476,6 +504,27 @@ mod tests {
assert!(!s.has_any().unwrap(), "acked-only table reads as empty");
}
/// `mark_done_until` acks everything at-or-below the threshold id and
/// leaves later rows untouched, mirroring `ack_until`'s inbox semantics.
#[test]
fn mark_done_until_acks_up_to_threshold_only() {
let (_dir, s) = store();
let (id1, _) = s.upsert("bash", None, "task 1", None).unwrap();
let (id2, _) = s.upsert("bash", None, "task 2", None).unwrap();
let (id3, _) = s.upsert("bash", None, "task 3", None).unwrap();
assert_eq!(
s.mark_done_until(id2).unwrap(),
2,
"acks id1 and id2, not id3"
);
let left = s.list(None).unwrap();
assert_eq!(left.len(), 1);
assert_eq!(left[0].id, id3);
// Idempotent: re-running over the same range acks nothing new.
assert_eq!(s.mark_done_until(id2).unwrap(), 0);
let _ = id1;
}
/// `reap_acked` only removes acked rows past the cutoff — a recent ack
/// and any un-acked row both survive.
#[test]