revise bulk-clear to explicit ids per mara's feedback, fix clippy line count

This commit is contained in:
damocles 2026-08-02 15:29:21 +02:00 committed by mara
commit bc69ee3b8f
7 changed files with 228 additions and 181 deletions

View file

@ -41,18 +41,15 @@ pub struct AckUntilArgs {
pub up_to: i64, pub up_to: i64,
} }
/// MCP tool args for `ack_todos_until`. /// MCP tool args for `mark_todos_done`.
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] #[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct AckTodosUntilArgs { pub struct MarkTodosDoneArgs {
/// Highest todo id to bulk-clear: every pending todo (bash-task /// The specific todo ids to clear (from `get_loose_ends`'s `todo #N`
/// completions, matrix unread, forge activity) with `id <= up_to` is /// lines) — every id in the list is acked in one call. Deliberately a
/// acked in one call — the todo-store analogue of `ack_until` for the /// list, not a range/threshold: only clears exactly what you pass, so
/// message inbox. `get_loose_ends` suggests a value in its truncation /// you don't risk acking a todo you haven't actually looked at. Unknown
/// summary when there are more todos than it renders individually; /// or already-acked ids are silently skipped.
/// pass that value (or the highest id you've actually triaged) to pub ids: Vec<i64>,
/// clear the backlog in one shot instead of cancelling ids one at a
/// time.
pub up_to: i64,
} }
/// MCP tool args for `remind`. Exactly one of `delay_seconds` or /// MCP tool args for `remind`. Exactly one of `delay_seconds` or

View file

@ -24,18 +24,18 @@ mod args;
mod render; mod render;
pub use args::{ pub use args::{
AckTodosUntilArgs, AckUntilArgs, AgentGetLooseEndsArgs, AnswerArgs, AskArgs, AckUntilArgs, AgentGetLooseEndsArgs, AnswerArgs, AskArgs, CancelLooseEndArgs,
CancelLooseEndArgs, CancelScheduleArgs, CreateRepoArgs, EditScheduleArgs, CancelScheduleArgs, CreateRepoArgs, EditScheduleArgs, FireScheduleNowArgs, GetAgentMetaArgs,
FireScheduleNowArgs, GetAgentMetaArgs, GetHostJournalArgs, GetLogsArgs, KillArgs, RecvArgs, GetHostJournalArgs, GetLogsArgs, KillArgs, MarkTodosDoneArgs, RecvArgs, RemindArgs,
RemindArgs, RequestInitConfigArgs, RequestSchedulePromptArgs, RestartArgs, SendArgs, RequestInitConfigArgs, RequestSchedulePromptArgs, RestartArgs, SendArgs, SetStatusArgs,
SetStatusArgs, StartArgs, UpdateArgs, UpdateMetaInputsArgs, StartArgs, UpdateArgs, UpdateMetaInputsArgs,
}; };
pub use render::{annotate_retries, format_ack, format_agent_meta, format_recv}; pub use render::{annotate_retries, format_ack, format_agent_meta, format_recv};
use render::{ use render::{
dial_agent_socket, format_matrix_summary, local_questions, local_reminders, local_todos, dial_agent_socket, format_matrix_summary, local_questions, local_reminders, local_todos,
loose_end_kind_label, mark_local_todo_done, mark_local_todos_done_until, loose_end_kind_label, mark_local_todo_done, mark_local_todos_done, matrix_unread_summary,
matrix_unread_summary, parse_loose_end_kind, render_loose_ends, reply_err, parse_loose_end_kind, render_loose_ends, reply_err,
}; };
/// Write (or remove) the status file in the agent's own `state/` directory. /// Write (or remove) the status file in the agent's own `state/` directory.
@ -349,9 +349,9 @@ impl AgentServer {
the relevant context. Each `question` or `reminder` row can be cancelled by \ the relevant context. Each `question` or `reminder` row can be cancelled by \
passing its id + kind to `cancel_loose_end`. Empty result is reported clearly. \ passing its id + kind to `cancel_loose_end`. Empty result is reported clearly. \
Todos are capped at 40 rendered rows (newest first) so a large backlog never makes \ Todos are capped at 40 rendered rows (newest first) so a large backlog never makes \
this call fail a trailer line reports how many more are pending and suggests an \ this call fail a trailer line reports how many more are pending; review the shown \
`ack_todos_until` value to bulk-clear the rest in one call instead of triaging \ batch, clear reviewed ids with `mark_todos_done`, then call again for the next batch \
hundreds of ids individually.\n\ instead of triaging hundreds of ids one `cancel_loose_end` at a time.\n\
Pass `agent: \"<name>\"` to inspect a specific peer agent's threads. Direct \ Pass `agent: \"<name>\"` to inspect a specific peer agent's threads. Direct \
child agents are always accessible. For non-children, the `query_agent_state` \ child agents are always accessible. For non-children, the `query_agent_state` \
capability is required without it the request is rejected with an error." capability is required without it the request is rejected with an error."
@ -552,27 +552,30 @@ impl AgentServer {
} }
#[tool( #[tool(
description = "Bulk-clear local todos (loose-ends v2 — bash-task completions, matrix \ description = "Bulk-clear specific local todos (loose-ends v2 — bash-task completions, \
unread, forge activity): every pending todo with `id <= up_to` is acked in one call, \ matrix unread, forge activity) by id in one call, instead of `cancel_loose_end`ing \
same shape as `ack_until` for the message inbox. Use this when `get_loose_ends` \ each one individually. Deliberately list-based, not range-based: pass exactly the ids \
reports more todos than it renders individually (its truncation summary suggests a \ you've actually looked at (typically the ones `get_loose_ends` just rendered) there \
value) or whenever a backlog has piled up past the point of clearing ids one at a \ is no `ack_until`-style 'clear everything below id N' shortcut for todos, since unlike \
time note the highest id you've actually triaged, or take the suggested value \ the sequentially-read message inbox, todos are heterogeneous unrelated items and a \
verbatim to clear the whole shown-as-hidden tail. Only affects YOUR todos; ids above \ blind range-clear risks silently acking something you never saw. When \
`up_to` stay pending. Returns how many were newly acked." `get_loose_ends` reports more todos than it renders (its truncation trailer says so), \
review the shown batch, clear the reviewed ids here, then call `get_loose_ends` again \
for the next batch. Unknown/already-acked ids are silently skipped. Returns how many \
were newly acked."
)] )]
async fn ack_todos_until(&self, Parameters(args): Parameters<AckTodosUntilArgs>) -> String { async fn mark_todos_done(&self, Parameters(args): Parameters<MarkTodosDoneArgs>) -> String {
let log = format!("{args:?}"); let log = format!("{args:?}");
run_tool_envelope("ack_todos_until", log, async move { run_tool_envelope("mark_todos_done", log, async move {
match mark_local_todos_done_until(args.up_to).await { match mark_local_todos_done(args.ids).await {
Some(hive_agent_sock::Response::Acked { count }) => { Some(hive_agent_sock::Response::Acked { count }) => {
format!("acked {count} todo(s) up to id {}", args.up_to) format!("acked {count} todo(s)")
} }
Some(hive_agent_sock::Response::Err { message }) => { Some(hive_agent_sock::Response::Err { message }) => {
format!("ack_todos_until failed: {message}") format!("mark_todos_done failed: {message}")
} }
Some(other) => format!("ack_todos_until unexpected response: {other:?}"), Some(other) => format!("mark_todos_done unexpected response: {other:?}"),
None => "ack_todos_until: local todo socket unavailable \ None => "mark_todos_done: local todo socket unavailable \
(HIVE_AGENT_SOCKET unset or harness unreachable)" (HIVE_AGENT_SOCKET unset or harness unreachable)"
.to_owned(), .to_owned(),
} }

View file

@ -146,11 +146,114 @@ fn msg_id_tag(id: i64) -> String {
/// transport to return at all, which is worse than a bug: it's a deadlock /// transport to return at all, which is worse than a bug: it's a deadlock
/// (the agent can't even see what's pending to start clearing it). Capping /// (the agent can't even see what's pending to start clearing it). Capping
/// here guarantees `get_loose_ends` always returns successfully; the /// here guarantees `get_loose_ends` always returns successfully; the
/// truncation summary tells the agent how to bulk-clear the rest via /// truncation summary tells the agent to review the shown batch, bulk-clear
/// `ack_todos_until` (see that tool's doc for why it's the intended escape /// the reviewed ids via `mark_todos_done`, then call again for the next
/// hatch, not a per-id triage loop). /// batch — deliberately NOT a "clear everything below id N" range escape
/// hatch (a reviewer's call: a range-based bulk-ack risks silently
/// acking something the agent never actually looked at, since todos are
/// heterogeneous unrelated items, not a sequentially-read stream the way
/// inbox messages are).
const MAX_RENDERED_TODOS: usize = 40; const MAX_RENDERED_TODOS: usize = 40;
/// Render one non-`Todo` [`hive_sh4re::LooseEnd`] variant onto `out`. Split
/// out of `render_loose_ends` to keep that function under clippy's
/// `too_many_lines` limit — `Todo` stays inline there since it also needs
/// the shared `shown_todos` counter.
fn render_one_loose_end(out: &mut String, t: &hive_sh4re::LooseEnd) {
use std::fmt::Write as _;
match t {
hive_sh4re::LooseEnd::Approval {
id,
agent,
commit_ref,
description,
age_seconds,
} => {
let desc = description
.as_deref()
.map(|d| format!("{d}"))
.unwrap_or_default();
let _ = writeln!(
out,
"- approval #{id} ({agent} @ {commit_ref}, {age_seconds}s old){desc}"
);
}
hive_sh4re::LooseEnd::Question {
id,
asker,
target,
question,
age_seconds,
} => {
let to = target.as_deref().unwrap_or("operator");
let _ = writeln!(
out,
"- question #{id} ({asker} → {to}, {age_seconds}s old): {question}"
);
}
hive_sh4re::LooseEnd::Reminder {
id,
owner,
message,
due_at,
age_seconds,
} => {
let _ = writeln!(
out,
"- reminder #{id} ({owner}, scheduled {age_seconds}s ago, due_at={due_at}): {message}"
);
}
hive_sh4re::LooseEnd::PendingMessages { count } => {
let _ = writeln!(
out,
"- {count} pending inbox message(s) — drain with recv (recv(max: {count}) to batch)"
);
}
hive_sh4re::LooseEnd::UnreadMatrix { rooms, summary } => {
let _ = write!(out, "- unread matrix messages in {rooms} room(s)");
if summary.is_empty() {
let _ = writeln!(
out,
" — use list_rooms + read_room to view, mark_read to clear"
);
} else {
let _ = writeln!(out, ":");
for line in summary.lines() {
let _ = writeln!(out, " {line}");
}
let _ = writeln!(
out,
" use list_rooms + read_room to view, mark_read to clear"
);
}
}
hive_sh4re::LooseEnd::Todo { .. } => {
// Handled inline by the caller (needs the shared shown-count).
}
}
}
/// Render one `Todo` [`hive_sh4re::LooseEnd`] onto `out`. Split out for the
/// same `too_many_lines` reason as [`render_one_loose_end`].
fn render_todo_loose_end(
out: &mut String,
id: i64,
subsystem: &str,
subsystem_key: Option<&str>,
summary: &str,
source: Option<&str>,
age_seconds: u64,
) {
use std::fmt::Write as _;
let key = subsystem_key.map(|k| format!(" {k}")).unwrap_or_default();
let src = source.map(|s| format!("{s}")).unwrap_or_default();
let _ = writeln!(
out,
"- todo #{id} [{subsystem}{key}, {age_seconds}s old]: {summary}{src} \
(cancel_loose_end kind:\"todo\" id:{id} to clear)"
);
}
/// Inner renderer for a `Vec<LooseEnd>` already extracted from the socket /// Inner renderer for a `Vec<LooseEnd>` already extracted from the socket
/// reply. Called by the `get_loose_ends` handler, which injects the /// reply. Called by the `get_loose_ends` handler, which injects the
/// `UnreadMatrix` entry before formatting. /// `UnreadMatrix` entry before formatting.
@ -162,113 +265,41 @@ pub(super) fn render_loose_ends(loose_ends: &[hive_sh4re::LooseEnd]) -> String {
let mut out = format!("{} loose end(s):\n", loose_ends.len()); let mut out = format!("{} loose end(s):\n", loose_ends.len());
let mut shown_todos = 0usize; let mut shown_todos = 0usize;
let mut hidden_todos = 0usize; let mut hidden_todos = 0usize;
let mut hidden_min_id: Option<i64> = None;
for t in loose_ends { for t in loose_ends {
if let hive_sh4re::LooseEnd::Todo { id, .. } = t let hive_sh4re::LooseEnd::Todo {
&& shown_todos >= MAX_RENDERED_TODOS id,
{ subsystem,
subsystem_key,
summary,
source,
age_seconds,
} = t
else {
render_one_loose_end(&mut out, t);
continue;
};
if shown_todos >= MAX_RENDERED_TODOS {
hidden_todos += 1; hidden_todos += 1;
hidden_min_id = Some(hidden_min_id.map_or(*id, |m| m.min(*id)));
continue; continue;
} }
match t { render_todo_loose_end(
hive_sh4re::LooseEnd::Approval { &mut out,
id, *id,
agent, subsystem,
commit_ref, subsystem_key.as_deref(),
description, summary,
age_seconds, source.as_deref(),
} => { *age_seconds,
let desc = description );
.as_deref() shown_todos += 1;
.map(|d| format!("{d}"))
.unwrap_or_default();
let _ = writeln!(
out,
"- approval #{id} ({agent} @ {commit_ref}, {age_seconds}s old){desc}"
);
}
hive_sh4re::LooseEnd::Question {
id,
asker,
target,
question,
age_seconds,
} => {
let to = target.as_deref().unwrap_or("operator");
let _ = writeln!(
out,
"- question #{id} ({asker} → {to}, {age_seconds}s old): {question}"
);
}
hive_sh4re::LooseEnd::Reminder {
id,
owner,
message,
due_at,
age_seconds,
} => {
let _ = writeln!(
out,
"- reminder #{id} ({owner}, scheduled {age_seconds}s ago, due_at={due_at}): {message}"
);
}
hive_sh4re::LooseEnd::PendingMessages { count } => {
let _ = writeln!(
out,
"- {count} pending inbox message(s) — drain with recv (recv(max: {count}) to batch)"
);
}
hive_sh4re::LooseEnd::UnreadMatrix { rooms, summary } => {
let _ = write!(out, "- unread matrix messages in {rooms} room(s)");
if summary.is_empty() {
let _ = writeln!(
out,
" — use list_rooms + read_room to view, mark_read to clear"
);
} else {
let _ = writeln!(out, ":");
for line in summary.lines() {
let _ = writeln!(out, " {line}");
}
let _ = writeln!(
out,
" use list_rooms + read_room to view, mark_read to clear"
);
}
}
hive_sh4re::LooseEnd::Todo {
id,
subsystem,
subsystem_key,
summary,
source,
age_seconds,
} => {
let key = subsystem_key
.as_deref()
.map(|k| format!(" {k}"))
.unwrap_or_default();
let src = source
.as_deref()
.map(|s| format!("{s}"))
.unwrap_or_default();
let _ = writeln!(
out,
"- todo #{id} [{subsystem}{key}, {age_seconds}s old]: {summary}{src} \
(cancel_loose_end kind:\"todo\" id:{id} to clear)"
);
shown_todos += 1;
}
}
} }
if hidden_todos > 0 { if hidden_todos > 0 {
let min_id = hidden_min_id.unwrap_or(0);
let _ = writeln!( let _ = writeln!(
out, out,
"- {hidden_todos} more todo(s) not shown (oldest not shown: #{min_id}) — \ "- {hidden_todos} more todo(s) not shown — review the ones above, then clear \
call ack_todos_until(up_to: {min_id}) to bulk-clear the old backlog, or \ the ids you've actually looked at with mark_todos_done(ids: [...]) (or \
cancel_loose_end kind:\"todo\" id:<N> to clear individually" cancel_loose_end kind:\"todo\" id:<N> for just one) and call get_loose_ends \
again to see the next batch; there is no blind bulk-clear-by-range for todos"
); );
} }
out out
@ -385,11 +416,11 @@ pub(super) async fn mark_local_todo_done(id: i64) -> Option<hive_agent_sock::Res
dial_agent_socket(&hive_agent_sock::Request::MarkTodoDone { id }).await dial_agent_socket(&hive_agent_sock::Request::MarkTodoDone { id }).await
} }
/// Bulk-ack every un-acked local todo with `id <= up_to`, via the harness's /// Bulk-ack an explicit list of local todo ids, via the harness's in-agent
/// in-agent socket — the todo-store analogue of `mark_local_todo_done`, for /// socket — the multi-id analogue of `mark_local_todo_done`, for the
/// the `ack_todos_until` tool. /// `mark_todos_done` tool.
pub(super) async fn mark_local_todos_done_until(up_to: i64) -> Option<hive_agent_sock::Response> { pub(super) async fn mark_local_todos_done(ids: Vec<i64>) -> Option<hive_agent_sock::Response> {
dial_agent_socket(&hive_agent_sock::Request::MarkTodosDoneUntil { up_to }).await dial_agent_socket(&hive_agent_sock::Request::MarkTodosDone { ids }).await
} }
/// Format a `Vec<MatrixRoomUnread>` into a per-room summary string. /// Format a `Vec<MatrixRoomUnread>` into a per-room summary string.

View file

@ -59,10 +59,12 @@ pub enum Request {
}, },
/// The agent marks one of its own todos done, by id. /// The agent marks one of its own todos done, by id.
MarkTodoDone { id: i64 }, MarkTodoDone { id: i64 },
/// The agent bulk-acks every un-acked todo with `id <= up_to` in one /// The agent bulk-acks a specific, explicit list of its own todos in
/// shot — the todo-store analogue of the message inbox's `ack_until`, /// one shot — for clearing a backlog that's piled up past the point of
/// for clearing a backlog that piled up past the point of per-id triage. /// per-id triage, without the range-based `ack_until`-style semantics
MarkTodosDoneUntil { up_to: i64 }, /// that risk silently acking something never actually looked at (see
/// `Todos::mark_done_many`'s doc for why it's ids, not a threshold).
MarkTodosDone { ids: Vec<i64> },
/// Schedule a reminder that fires into this agent's own turn loop at /// Schedule a reminder that fires into this agent's own turn loop at
/// `timing` (harness-local — no broker round-trip). Same semantics as /// `timing` (harness-local — no broker round-trip). Same semantics as
/// the old broker `Remind` request. `file_path`, when set, is where the /// the old broker `Remind` request. `file_path`, when set, is where the

View file

@ -2,7 +2,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
Tools (hyperhive surface). Full signature + behavior for each comes from the tool's own MCP description (you already received it via the MCP tool schema) — this is just the map of what exists and which ones are gated, so you know where to look: Tools (hyperhive surface). Full signature + behavior for each comes from the tool's own MCP description (you already received it via the MCP tool schema) — this is just the map of what exists and which ones are gated, so you know where to look:
- **Inbox / messaging** (always available): `mcp__hyperhive__recv`, `mcp__hyperhive__ack_until`, `mcp__hyperhive__send`, `mcp__hyperhive__ask`, `mcp__hyperhive__answer`, `mcp__hyperhive__get_loose_ends`, `mcp__hyperhive__cancel_loose_end`, `mcp__hyperhive__ack_todos_until`, `mcp__hyperhive__remind`, `mcp__hyperhive__set_status`, `mcp__hyperhive__get_agent_meta`. Two habits worth internalizing beyond the tool descriptions themselves: prefer ending the turn over repeatedly polling `recv` when idle (only turn-boundaries observe in-container todo wakes — bash-task completions, matrix unread, forge activity — and ending the turn is also your checkpoint); and `ask`/`answer` are async — `ask` returns immediately with a question id, the reply lands later as a `question_answered` system event, never block a turn waiting on it inline. If `get_loose_ends` reports a large todo backlog (it caps rendered rows at 40), don't try to triage hundreds of ids by hand — call `ack_todos_until` with the suggested cutoff to bulk-clear the stale tail in one shot. - **Inbox / messaging** (always available): `mcp__hyperhive__recv`, `mcp__hyperhive__ack_until`, `mcp__hyperhive__send`, `mcp__hyperhive__ask`, `mcp__hyperhive__answer`, `mcp__hyperhive__get_loose_ends`, `mcp__hyperhive__cancel_loose_end`, `mcp__hyperhive__mark_todos_done`, `mcp__hyperhive__remind`, `mcp__hyperhive__set_status`, `mcp__hyperhive__get_agent_meta`. Two habits worth internalizing beyond the tool descriptions themselves: prefer ending the turn over repeatedly polling `recv` when idle (only turn-boundaries observe in-container todo wakes — bash-task completions, matrix unread, forge activity — and ending the turn is also your checkpoint); and `ask`/`answer` are async — `ask` returns immediately with a question id, the reply lands later as a `question_answered` system event, never block a turn waiting on it inline. If `get_loose_ends` reports a large todo backlog (it caps rendered rows at 40), don't try to cancel hundreds of ids one at a time — review the shown batch and clear the ones you've actually looked at in one `mark_todos_done(ids: [...])` call, then call `get_loose_ends` again for the next batch. There's deliberately no blind range-clear for todos (unlike `ack_until` for messages) — only clear ids you've reviewed.
- **Extra MCP tools** (some agents only): `mcp__<server>__<tool>` — agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. First-class tools, already operator-approved at deploy time. - **Extra MCP tools** (some agents only): `mcp__<server>__<tool>` — agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. First-class tools, already operator-approved at deploy time.
- **Lifecycle** (_requires `lifecycle` tool group_, direct children only, no approval needed): `restart`, `kill`, `start`, `update`, `list_containers`. - **Lifecycle** (_requires `lifecycle` tool group_, direct children only, no approval needed): `restart`, `kill`, `start`, `update`, `list_containers`.
- **Approvals** (_requires `approvals` tool group_, queues an operator approval): `request_init_config`, `request_apply_commit`, `request_update_meta_inputs`. - **Approvals** (_requires `approvals` tool group_, queues an operator approval): `request_init_config`, `request_apply_commit`, `request_update_meta_inputs`.

View file

@ -202,7 +202,7 @@ fn dispatch(
} => clear_todo(store, &subsystem, key.as_deref(), all), } => clear_todo(store, &subsystem, key.as_deref(), all),
Request::ListTodos { subsystem } => list_todos(store, subsystem.as_deref()), Request::ListTodos { subsystem } => list_todos(store, subsystem.as_deref()),
Request::MarkTodoDone { id } => mark_todo_done(store, id), Request::MarkTodoDone { id } => mark_todo_done(store, id),
Request::MarkTodosDoneUntil { up_to } => mark_todos_done_until(store, up_to), Request::MarkTodosDone { ids } => mark_todos_done(store, &ids),
Request::StoreReminder { Request::StoreReminder {
message, message,
timing, timing,
@ -418,13 +418,13 @@ fn mark_todo_done(store: &Todos, id: i64) -> Response {
} }
} }
/// `MarkTodosDoneUntil` handler: bulk-acks every un-acked todo id at or /// `MarkTodosDone` handler: bulk-acks an explicit list of todo ids — the
/// below `up_to` — the escape hatch for a backlog too large to triage /// escape hatch for a backlog too large to triage one-by-one (see
/// one-by-one (see `Todos::mark_done_until`'s doc for why it's needed). /// `Todos::mark_done_many`'s doc for why it's ids, not a threshold).
fn mark_todos_done_until(store: &Todos, up_to: i64) -> Response { fn mark_todos_done(store: &Todos, ids: &[i64]) -> Response {
match store.mark_done_until(up_to) { match store.mark_done_many(ids) {
Ok(count) => { Ok(count) => {
tracing::debug!(up_to, count, "todo bulk mark-done"); tracing::debug!(n_ids = ids.len(), count, "todo bulk mark-done");
Response::Acked { Response::Acked {
count: u64::try_from(count).unwrap_or(0), count: u64::try_from(count).unwrap_or(0),
} }

View file

@ -245,17 +245,23 @@ impl Todos {
Ok(n) Ok(n)
} }
/// Bulk-ack every un-acked todo with `id <= up_to` in one shot — the /// Bulk-ack a specific, explicit list of todo ids in one shot. Exists
/// same "note the highest id you've seen, clear everything up to it" /// for the case a per-id `mark_done` loop isn't worth the round-trips:
/// pattern the message inbox's `ack_until` already gives agents, applied /// an agent that hasn't called `get_loose_ends` in a long stretch (or
/// to this store. Exists for the case a per-id triage loop isn't worth /// one whose producers pile up faster than it triages) can end up with
/// it: an agent that hasn't called `get_loose_ends` in a long stretch /// a backlog large enough that clearing it one call at a time is
/// (or one whose producers pile up faster than it triages) can end up /// impractical. Deliberately **explicit ids, not a `<= threshold`
/// with a backlog large enough that the rendered list itself becomes /// range** — a reviewer's call on the design: a range-based
/// unwieldy — clearing it in one call is the escape hatch, same /// bulk-ack risks silently acking something the agent never actually
/// `acked`-not-deleted semantics as [`Self::mark_done`] (a reconciled /// looked at, since todos are heterogeneous unrelated items (bash /
/// producer's next `upsert` still sees the row to compare against). /// matrix / forge) rather than a sequentially-read stream the way inbox
/// Returns the number of rows newly acked. /// messages are. The caller is expected to have looked at each id
/// (typically the ids `get_loose_ends` just rendered) before passing
/// them here. Same `acked`-not-deleted semantics as [`Self::mark_done`]
/// (a reconciled producer's next `upsert` still sees the row to compare
/// against). Unknown/already-acked ids are silently skipped — same "not
/// a new action" idempotence as the single-id path. Returns the number
/// of rows newly acked.
/// ///
/// # Errors /// # Errors
/// ///
@ -264,13 +270,20 @@ impl Todos {
/// # Panics /// # Panics
/// ///
/// Panics if the connection mutex is poisoned. /// Panics if the connection mutex is poisoned.
pub fn mark_done_until(&self, up_to: i64) -> Result<usize> { pub fn mark_done_many(&self, ids: &[i64]) -> Result<usize> {
if ids.is_empty() {
return Ok(0);
}
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let n = conn.execute( let now = Utc::now().timestamp();
"UPDATE todos SET acked = 1, acked_at = ?1 WHERE acked = 0 AND id <= ?2", let mut total = 0usize;
params![Utc::now().timestamp(), up_to], for id in ids {
)?; total += conn.execute(
Ok(n) "UPDATE todos SET acked = 1, acked_at = ?1 WHERE acked = 0 AND id = ?2",
params![now, id],
)?;
}
Ok(total)
} }
/// List todos, newest-updated first. `subsystem = Some(..)` filters to /// List todos, newest-updated first. `subsystem = Some(..)` filters to
@ -504,25 +517,26 @@ mod tests {
assert!(!s.has_any().unwrap(), "acked-only table reads as empty"); assert!(!s.has_any().unwrap(), "acked-only table reads as empty");
} }
/// `mark_done_until` acks everything at-or-below the threshold id and /// `mark_done_many` acks exactly the ids passed, leaves the rest
/// leaves later rows untouched, mirroring `ack_until`'s inbox semantics. /// untouched — no threshold/range semantics.
#[test] #[test]
fn mark_done_until_acks_up_to_threshold_only() { fn mark_done_many_acks_only_the_listed_ids() {
let (_dir, s) = store(); let (_dir, s) = store();
let (id1, _) = s.upsert("bash", None, "task 1", None).unwrap(); let (id1, _) = s.upsert("bash", None, "task 1", None).unwrap();
let (id2, _) = s.upsert("bash", None, "task 2", None).unwrap(); let (id2, _) = s.upsert("bash", None, "task 2", None).unwrap();
let (id3, _) = s.upsert("bash", None, "task 3", None).unwrap(); let (id3, _) = s.upsert("bash", None, "task 3", None).unwrap();
assert_eq!( assert_eq!(
s.mark_done_until(id2).unwrap(), s.mark_done_many(&[id1, id3]).unwrap(),
2, 2,
"acks id1 and id2, not id3" "acks id1 and id3, not id2 — not a range"
); );
let left = s.list(None).unwrap(); let left = s.list(None).unwrap();
assert_eq!(left.len(), 1); assert_eq!(left.len(), 1);
assert_eq!(left[0].id, id3); assert_eq!(left[0].id, id2);
// Idempotent: re-running over the same range acks nothing new. // Idempotent: re-running over the same ids acks nothing new.
assert_eq!(s.mark_done_until(id2).unwrap(), 0); assert_eq!(s.mark_done_many(&[id1, id3]).unwrap(), 0);
let _ = id1; // Empty input is a no-op, not an error.
assert_eq!(s.mark_done_many(&[]).unwrap(), 0);
} }
/// `reap_acked` only removes acked rows past the cutoff — a recent ack /// `reap_acked` only removes acked rows past the cutoff — a recent ack