Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b5e5a30f4 | ||
|
|
bc69ee3b8f | ||
|
|
eb570c003d |
7 changed files with 292 additions and 100 deletions
|
|
@ -41,6 +41,15 @@ pub struct AckUntilArgs {
|
|||
pub up_to: i64,
|
||||
}
|
||||
|
||||
/// MCP tool args for `mark_todos_done`.
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct MarkTodosDoneArgs {
|
||||
/// Todo ids to clear (from `get_loose_ends`'s `todo #N` lines). Only
|
||||
/// these ids are acked — not a range. Unknown/already-acked ids are
|
||||
/// silently skipped.
|
||||
pub ids: Vec<i64>,
|
||||
}
|
||||
|
||||
/// MCP tool args for `remind`. Exactly one of `delay_seconds` or
|
||||
/// `at_unix_timestamp` must be set; both / neither is a tool-side error.
|
||||
/// Hides the tagged `ReminderTiming` enum behind a flatter schema so the
|
||||
|
|
|
|||
|
|
@ -26,16 +26,16 @@ mod render;
|
|||
pub use args::{
|
||||
AckUntilArgs, AgentGetLooseEndsArgs, AnswerArgs, AskArgs, CancelLooseEndArgs,
|
||||
CancelScheduleArgs, CreateRepoArgs, EditScheduleArgs, FireScheduleNowArgs, GetAgentMetaArgs,
|
||||
GetHostJournalArgs, GetLogsArgs, KillArgs, RecvArgs, RemindArgs, RequestInitConfigArgs,
|
||||
RequestSchedulePromptArgs, RestartArgs, SendArgs, SetStatusArgs, StartArgs, UpdateArgs,
|
||||
UpdateMetaInputsArgs,
|
||||
GetHostJournalArgs, GetLogsArgs, KillArgs, MarkTodosDoneArgs, RecvArgs, RemindArgs,
|
||||
RequestInitConfigArgs, RequestSchedulePromptArgs, RestartArgs, SendArgs, SetStatusArgs,
|
||||
StartArgs, UpdateArgs, UpdateMetaInputsArgs,
|
||||
};
|
||||
pub use render::{annotate_retries, format_ack, format_agent_meta, format_recv};
|
||||
|
||||
use render::{
|
||||
dial_agent_socket, format_matrix_summary, local_questions, local_reminders, local_todos,
|
||||
loose_end_kind_label, mark_local_todo_done, matrix_unread_summary, parse_loose_end_kind,
|
||||
render_loose_ends, reply_err,
|
||||
loose_end_kind_label, mark_local_todo_done, mark_local_todos_done, matrix_unread_summary,
|
||||
parse_loose_end_kind, render_loose_ends, reply_err,
|
||||
};
|
||||
|
||||
/// Write (or remove) the status file in the agent's own `state/` directory.
|
||||
|
|
@ -347,7 +347,9 @@ impl AgentServer {
|
|||
at turn start to remember what you owe / what's owed to you without scrolling \
|
||||
inbox history. Output is a short bulleted list with ids, ages in seconds, and \
|
||||
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.\n\
|
||||
passing its id + kind to `cancel_loose_end`. Empty result is reported clearly. Todos \
|
||||
cap at 40 rows; a trailer line says how many more are pending — clear the shown ones \
|
||||
with `mark_todos_done`, then call again for the rest.\n\
|
||||
Pass `agent: \"<name>\"` to inspect a specific peer agent's threads. Direct \
|
||||
child agents are always accessible. For non-children, the `query_agent_state` \
|
||||
capability is required — without it the request is rejected with an error."
|
||||
|
|
@ -471,11 +473,9 @@ impl AgentServer {
|
|||
`kind` may also be `\"approval\"` to withdraw a pending approval you submitted \
|
||||
(before the operator acts on it) — root agent (`ruth`) only; the server rejects \
|
||||
`approval` kind for all other callers.\n\
|
||||
`kind` may also be `\"todo\"` to clear one of your own loose-ends-v2 todos \
|
||||
(bash-task completions, matrix unread, forge activity — the id in the \
|
||||
`get_loose_ends` `todo #N` line) — this dials the in-container socket directly, \
|
||||
no bash task involved, so it's safe to call repeatedly without spawning more \
|
||||
todos."
|
||||
`kind` may also be `\"todo\"` to clear one of your own loose-ends-v2 todos (the id \
|
||||
from a `get_loose_ends` `todo #N` line) — dials the in-agent socket directly, safe \
|
||||
to call repeatedly."
|
||||
)]
|
||||
async fn cancel_loose_end(&self, Parameters(args): Parameters<CancelLooseEndArgs>) -> String {
|
||||
let log = format!("{args:?}");
|
||||
|
|
@ -547,6 +547,33 @@ impl AgentServer {
|
|||
.await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Bulk-clear specific todos by id in one call, instead of \
|
||||
`cancel_loose_end`ing each one. List-based, not range-based — no `ack_until`-style \
|
||||
'clear below id N' for todos, since a blind range-clear risks acking something you \
|
||||
never saw. Pass the ids you've actually reviewed (typically what `get_loose_ends` \
|
||||
just showed); unknown/already-acked ids are silently skipped. Returns how many were \
|
||||
newly acked."
|
||||
)]
|
||||
async fn mark_todos_done(&self, Parameters(args): Parameters<MarkTodosDoneArgs>) -> String {
|
||||
let log = format!("{args:?}");
|
||||
run_tool_envelope("mark_todos_done", log, async move {
|
||||
match mark_local_todos_done(args.ids).await {
|
||||
Some(hive_agent_sock::Response::Acked { count }) => {
|
||||
format!("acked {count} todo(s)")
|
||||
}
|
||||
Some(hive_agent_sock::Response::Err { message }) => {
|
||||
format!("mark_todos_done failed: {message}")
|
||||
}
|
||||
Some(other) => format!("mark_todos_done unexpected response: {other:?}"),
|
||||
None => "mark_todos_done: local todo socket unavailable \
|
||||
(HIVE_AGENT_SOCKET unset or harness unreachable)"
|
||||
.to_owned(),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Create a git repo through hive-c0re. You CANNOT create repos with your \
|
||||
own forge token (creation is disabled) — this is the only path. The repo is created in \
|
||||
|
|
|
|||
|
|
@ -136,6 +136,124 @@ fn msg_id_tag(id: i64) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
/// Hard cap on how many individual `Todo` lines `render_loose_ends` will
|
||||
/// emit. Approvals/questions/reminders stay naturally bounded (they're
|
||||
/// triaged interactively and don't self-multiply), but todos are pushed by
|
||||
/// unattended producers (matrix/bash/forge) — an agent that goes a long
|
||||
/// stretch without calling `get_loose_ends`, or whose producers outpace its
|
||||
/// triage, can accumulate hundreds of them. Rendering all of them
|
||||
/// unconditionally risks producing a tool result too large for the MCP
|
||||
/// 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
|
||||
/// here guarantees `get_loose_ends` always returns successfully; the
|
||||
/// truncation summary tells the agent to review the shown batch, bulk-clear
|
||||
/// the reviewed ids via `mark_todos_done`, then call again for the next
|
||||
/// 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;
|
||||
|
||||
/// 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
|
||||
/// reply. Called by the `get_loose_ends` handler, which injects the
|
||||
/// `UnreadMatrix` entry before formatting.
|
||||
|
|
@ -145,96 +263,42 @@ pub(super) fn render_loose_ends(loose_ends: &[hive_sh4re::LooseEnd]) -> String {
|
|||
return "(no loose ends)".to_owned();
|
||||
}
|
||||
let mut out = format!("{} loose end(s):\n", loose_ends.len());
|
||||
let mut shown_todos = 0usize;
|
||||
let mut hidden_todos = 0usize;
|
||||
for t in loose_ends {
|
||||
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 {
|
||||
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)"
|
||||
);
|
||||
}
|
||||
let hive_sh4re::LooseEnd::Todo {
|
||||
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;
|
||||
continue;
|
||||
}
|
||||
render_todo_loose_end(
|
||||
&mut out,
|
||||
*id,
|
||||
subsystem,
|
||||
subsystem_key.as_deref(),
|
||||
summary,
|
||||
source.as_deref(),
|
||||
*age_seconds,
|
||||
);
|
||||
shown_todos += 1;
|
||||
}
|
||||
if hidden_todos > 0 {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"- {hidden_todos} more todo(s) not shown — clear reviewed ids with \
|
||||
mark_todos_done(ids: [...]), then call get_loose_ends again for the rest"
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
@ -350,6 +414,13 @@ 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
|
||||
}
|
||||
|
||||
/// Bulk-ack an explicit list of local todo ids, via the harness's in-agent
|
||||
/// socket — the multi-id analogue of `mark_local_todo_done`, for the
|
||||
/// `mark_todos_done` tool.
|
||||
pub(super) async fn mark_local_todos_done(ids: Vec<i64>) -> Option<hive_agent_sock::Response> {
|
||||
dial_agent_socket(&hive_agent_sock::Request::MarkTodosDone { ids }).await
|
||||
}
|
||||
|
||||
/// Format a `Vec<MatrixRoomUnread>` into a per-room summary string.
|
||||
/// Single room / single message collapses to one line; multi-room
|
||||
/// expands to a bulleted list. Returns an empty string for empty input.
|
||||
|
|
|
|||
|
|
@ -59,6 +59,12 @@ pub enum Request {
|
|||
},
|
||||
/// The agent marks one of its own todos done, by id.
|
||||
MarkTodoDone { id: i64 },
|
||||
/// The agent bulk-acks a specific, explicit list of its own todos in
|
||||
/// one shot — for clearing a backlog that's piled up past the point of
|
||||
/// per-id triage, without the range-based `ack_until`-style semantics
|
||||
/// 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
|
||||
/// `timing` (harness-local — no broker round-trip). Same semantics as
|
||||
/// the old broker `Remind` request. `file_path`, when set, is where the
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
- **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__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.
|
||||
- **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. For a large todo backlog (`get_loose_ends` caps at 40 rows), clear reviewed ids in bulk with `mark_todos_done` rather than cancelling one at a time — there's no blind range-clear, only ids you've actually looked at.
|
||||
- **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`.
|
||||
- **Approvals** (_requires `approvals` tool group_, queues an operator approval): `request_init_config`, `request_apply_commit`, `request_update_meta_inputs`.
|
||||
|
|
|
|||
|
|
@ -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::MarkTodosDone { ids } => mark_todos_done(store, &ids),
|
||||
Request::StoreReminder {
|
||||
message,
|
||||
timing,
|
||||
|
|
@ -417,6 +418,21 @@ fn mark_todo_done(store: &Todos, id: i64) -> Response {
|
|||
}
|
||||
}
|
||||
|
||||
/// `MarkTodosDone` handler: bulk-acks an explicit list of todo ids — the
|
||||
/// escape hatch for a backlog too large to triage one-by-one (see
|
||||
/// `Todos::mark_done_many`'s doc for why it's ids, not a threshold).
|
||||
fn mark_todos_done(store: &Todos, ids: &[i64]) -> Response {
|
||||
match store.mark_done_many(ids) {
|
||||
Ok(count) => {
|
||||
tracing::debug!(n_ids = ids.len(), 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
|
||||
|
|
|
|||
|
|
@ -245,6 +245,47 @@ impl Todos {
|
|||
Ok(n)
|
||||
}
|
||||
|
||||
/// Bulk-ack a specific, explicit list of todo ids in one shot. Exists
|
||||
/// for the case a per-id `mark_done` loop isn't worth the round-trips:
|
||||
/// 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 clearing it one call at a time is
|
||||
/// impractical. Deliberately **explicit ids, not a `<= threshold`
|
||||
/// range** — a reviewer's call on the design: a range-based
|
||||
/// bulk-ack risks silently acking something the agent never actually
|
||||
/// looked at, since todos are heterogeneous unrelated items (bash /
|
||||
/// matrix / forge) rather than a sequentially-read stream the way inbox
|
||||
/// 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
|
||||
///
|
||||
/// Propagates the sqlite update failure.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the connection mutex is poisoned.
|
||||
pub fn mark_done_many(&self, ids: &[i64]) -> Result<usize> {
|
||||
if ids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let now = Utc::now().timestamp();
|
||||
let mut total = 0usize;
|
||||
for id in ids {
|
||||
total += conn.execute(
|
||||
"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
|
||||
/// 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 +517,28 @@ mod tests {
|
|||
assert!(!s.has_any().unwrap(), "acked-only table reads as empty");
|
||||
}
|
||||
|
||||
/// `mark_done_many` acks exactly the ids passed, leaves the rest
|
||||
/// untouched — no threshold/range semantics.
|
||||
#[test]
|
||||
fn mark_done_many_acks_only_the_listed_ids() {
|
||||
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_many(&[id1, id3]).unwrap(),
|
||||
2,
|
||||
"acks id1 and id3, not id2 — not a range"
|
||||
);
|
||||
let left = s.list(None).unwrap();
|
||||
assert_eq!(left.len(), 1);
|
||||
assert_eq!(left[0].id, id2);
|
||||
// Idempotent: re-running over the same ids acks nothing new.
|
||||
assert_eq!(s.mark_done_many(&[id1, id3]).unwrap(), 0);
|
||||
// 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
|
||||
/// and any un-acked row both survive.
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Reference in a new issue