clippy: fix lints that crane's cargoClippy properly enforces (#538)
The naersk → crane swap in the parent commit flips clippy from silently passing to actually failing on `-D warnings` (naersk's `mode = "clippy"` mangled the `--` separator so the deny never took effect). This commit clears the surfaced lints so the workspace builds clean under the new enforcement — every fix is mechanical and preserves behaviour. Tests still pass (160 across the workspace). Auto-fixes via `cargo clippy --fix`: - `doc_markdown` (19 sites): bare identifiers in doc comments wrapped in backticks - `format_in_format_args`, `explicit_into_iter_loop`, `redundant_closure_for_method_calls`, `useless_conversion`, and a few more — mechanical rewrites of the kind cargo can apply safely. Hand-fixed: - `match_same_arms` (forge_notify::is_atx_heading): two arms returning `true` collapsed into a single `matches!` pattern. - `cast_sign_loss` + `format_push_string` (mcp.rs status formatter): guarded `i64 → u64` through `u64::try_from(…).unwrap_or(0)` (status timestamps are always positive in practice; clamp the skew edge to 0) and swapped `out.push_str(&format!(…))` for `write!` into the buffer with an infallible-writer `let _ =`. - `doc_lazy_continuation` in turn.rs + manager_server.rs + sh4re/lib.rs: doc paragraphs that the markdown parser was treating as list-item continuations got either a separating blank line or a `/`-for-`+` word swap so the parser stops seeing a list. - `unused_async` (manager_server::handle_request_schedule_prompt): function has no `.await`; dropped the `async` and its `.await` call site. - `needless_pass_by_value` (scheduled_prompts::submit): take `&NewSchedule` instead of moving the struct in; updated two prod callers and eight test sites to pass references. - `type_complexity` (approvals::mark_cancelled): hoisted the 7-tuple SELECT row shape into a `type CancelLookupRow = (…);` alias. Allow-with-reason for intentional patterns: - `option_option` (6 sites across dashboard / scheduled_prompts / manager_server): `Option<Option<T>>` carries three-state PATCH semantics (missing key = leave alone, `Some(None)` = clear, `Some(Some(v))` = set). Collapsing to `Option<T>` loses the "clear" state. - `dead_code` (rebuild_queue::QueueKind::Destroy / QueueSource::CrashRecover; topology::parent_of / default_seed): wire-shape variants + API surfaces kept for the upcoming features (#361 follow-ups, future `Destroy` queue routing, crash-recovery path). Allowed at the variant / function level with the rationale in `reason = "…"`. - `too_many_lines` on three specific call-sites: a 117-line exhaustive-variant test (dashboard_events::kind_tag_matches_…), the meta-flake string template renderer (meta::render_flake_with_lookup), and the notification poll loop (forge_notify::poll_once) — splitting any of them would just hide the contiguous shape they exist to keep visible. `nix flake check` formatting target is still broken on main itself (pre-existing nixfmt drift across ~28 files unrelated to this PR); left alone here so the scope stays "crane port + lints the port exposed" and the operator's review doesn't have to triage drive-by nixfmt churn.
This commit is contained in:
parent
4b6c733afb
commit
9ed58ab96d
17 changed files with 643 additions and 463 deletions
|
|
@ -143,10 +143,7 @@ pub enum LiveEvent {
|
|||
/// compacting). `since_unix` matches `Bus::state_snapshot().1`
|
||||
/// so the client's elapsed-time ticker keeps progressing across
|
||||
/// SSE reconnects without drift.
|
||||
TurnStateChanged {
|
||||
state: TurnState,
|
||||
since_unix: i64,
|
||||
},
|
||||
TurnStateChanged { state: TurnState, since_unix: i64 },
|
||||
}
|
||||
|
||||
/// sqlite-backed event log. Wraps a `Connection` behind a `Mutex` so the
|
||||
|
|
@ -284,10 +281,12 @@ impl TokenUsage {
|
|||
let model_usage = v.get("modelUsage")?;
|
||||
let map = model_usage.as_object()?;
|
||||
for (_model, stats) in map {
|
||||
if let Some(w) = stats.get("contextWindow").and_then(serde_json::Value::as_u64) {
|
||||
if w > 0 {
|
||||
return Some(w);
|
||||
}
|
||||
if let Some(w) = stats
|
||||
.get("contextWindow")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
&& w > 0
|
||||
{
|
||||
return Some(w);
|
||||
}
|
||||
}
|
||||
None
|
||||
|
|
@ -353,18 +352,21 @@ pub fn context_window_tokens(model: &str) -> u64 {
|
|||
// Per-model env vars set by `hyperhive.contextWindowTokens` in Nix.
|
||||
for (key, val) in std::env::vars() {
|
||||
if let Some(suffix) = key.strip_prefix("HIVE_CONTEXT_WINDOW_TOKENS_")
|
||||
&& !suffix.is_empty() && m.contains(&suffix.to_ascii_lowercase())
|
||||
&& let Ok(v) = val.trim().parse::<u64>()
|
||||
&& v > 0 {
|
||||
return v;
|
||||
}
|
||||
&& !suffix.is_empty()
|
||||
&& m.contains(&suffix.to_ascii_lowercase())
|
||||
&& let Ok(v) = val.trim().parse::<u64>()
|
||||
&& v > 0
|
||||
{
|
||||
return v;
|
||||
}
|
||||
}
|
||||
// Global override (single value, any model).
|
||||
if let Ok(s) = std::env::var("HIVE_CONTEXT_WINDOW_TOKENS")
|
||||
&& let Ok(v) = s.trim().parse::<u64>()
|
||||
&& v > 0 {
|
||||
return v;
|
||||
}
|
||||
&& v > 0
|
||||
{
|
||||
return v;
|
||||
}
|
||||
// Hard fallback for dev/test outside NixOS where env vars aren't set.
|
||||
200_000
|
||||
}
|
||||
|
|
@ -450,9 +452,10 @@ impl Bus {
|
|||
// persisted runtime override > compiled-in DEFAULT_MODEL.
|
||||
// The nix config always wins on rebuild; the persisted file is kept
|
||||
// for within-session tracking only (see persist_model / set_model).
|
||||
let initial_model = configured_model()
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| load_model().unwrap_or_else(|| DEFAULT_MODEL.to_owned()));
|
||||
let initial_model = configured_model().map_or_else(
|
||||
|| load_model().unwrap_or_else(|| DEFAULT_MODEL.to_owned()),
|
||||
str::to_owned,
|
||||
);
|
||||
// Restore rate_limited from the sentinel file — if the harness
|
||||
// crashed while parked, we should still show the right status on
|
||||
// cold load until the next turn clears it.
|
||||
|
|
@ -560,7 +563,8 @@ impl Bus {
|
|||
pub fn record_turn_usage(&self, ctx: TokenUsage, cost: TokenUsage) {
|
||||
*self.last_ctx_usage.lock().unwrap() = Some(ctx);
|
||||
*self.last_cost_usage.lock().unwrap() = Some(cost);
|
||||
self.last_turn_ended_unix.store(now_unix(), Ordering::Relaxed);
|
||||
self.last_turn_ended_unix
|
||||
.store(now_unix(), Ordering::Relaxed);
|
||||
self.emit(LiveEvent::TokenUsageChanged { ctx, cost });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -162,11 +162,7 @@ pub async fn run(socket: PathBuf, is_manager: bool) {
|
|||
|
||||
/// Fetch a JSON value from a URL using the agent's forge token. Returns
|
||||
/// `None` on any HTTP or parse error (best-effort enrichment).
|
||||
async fn fetch_json(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
token: &str,
|
||||
) -> Option<serde_json::Value> {
|
||||
async fn fetch_json(client: &reqwest::Client, url: &str, token: &str) -> Option<serde_json::Value> {
|
||||
let resp = client
|
||||
.get(url)
|
||||
.header("Authorization", format!("token {token}"))
|
||||
|
|
@ -207,7 +203,7 @@ fn notif_type_label(t: &str) -> &str {
|
|||
/// (`heading\n===`) are not handled here — rarer in practice and
|
||||
/// would need multi-line lookahead; revisit if it actually shows up.
|
||||
///
|
||||
/// **ATX shape strictly:** CommonMark requires a space (or end-of-line)
|
||||
/// **ATX shape strictly:** `CommonMark` requires a space (or end-of-line)
|
||||
/// after the 1-6 leading `#`s to count as an ATX heading. Lines like
|
||||
/// `#tag`, `#123`, `#!/bin/bash` are NOT headings — passing them through
|
||||
/// untouched avoids the cosmetic noise argus flagged on PR #518 (`\#tag`
|
||||
|
|
@ -236,7 +232,7 @@ fn escape_md_headings(body: &str) -> String {
|
|||
out
|
||||
}
|
||||
|
||||
/// Strict CommonMark ATX-heading detector: 1-6 leading `#`s followed
|
||||
/// Strict `CommonMark` ATX-heading detector: 1-6 leading `#`s followed
|
||||
/// by either a space, tab, or end-of-line. Anything tighter (`#tag`,
|
||||
/// `#123`) is a non-heading line that the renderer will not promote.
|
||||
fn is_atx_heading(line: &str) -> bool {
|
||||
|
|
@ -244,11 +240,10 @@ fn is_atx_heading(line: &str) -> bool {
|
|||
if !(1..=6).contains(&hashes) {
|
||||
return false;
|
||||
}
|
||||
match line.as_bytes().get(hashes) {
|
||||
None => true, // bare `#` / `##` / ... on its own line
|
||||
Some(b' ') | Some(b'\t') => true, // proper ATX with space/tab after #s
|
||||
_ => false, // `#tag` / `#123` — not a heading
|
||||
}
|
||||
// Bare `#` / `##` / ... on its own line, or proper ATX with a
|
||||
// space/tab after the run of `#`s; anything else (`#tag` / `#123`)
|
||||
// is not a heading.
|
||||
matches!(line.as_bytes().get(hashes), None | Some(b' ' | b'\t'))
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
|
|
@ -390,7 +385,9 @@ async fn format_notification(
|
|||
|
||||
// API URLs for fetching content
|
||||
let subject_api_url = notif["subject"]["url"].as_str().unwrap_or("");
|
||||
let comment_api_url = notif["subject"]["latest_comment_url"].as_str().unwrap_or("");
|
||||
let comment_api_url = notif["subject"]["latest_comment_url"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
let comment_html_url = notif["subject"]["latest_comment_html_url"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
|
|
@ -411,9 +408,27 @@ async fn format_notification(
|
|||
// by creation/state-change of the subject itself.
|
||||
let has_comment = !comment_api_url.is_empty() && comment_api_url != subject_api_url;
|
||||
|
||||
let meta = NotifMeta { title, notif_type, html_url, num, repo, meta_suffix, reason, subject, is_pr };
|
||||
let meta = NotifMeta {
|
||||
title,
|
||||
notif_type,
|
||||
html_url,
|
||||
num,
|
||||
repo,
|
||||
meta_suffix,
|
||||
reason,
|
||||
subject,
|
||||
is_pr,
|
||||
};
|
||||
if has_comment {
|
||||
format_comment_notification(client, token, &meta, comment_api_url, comment_html_url, own_login).await
|
||||
format_comment_notification(
|
||||
client,
|
||||
token,
|
||||
&meta,
|
||||
comment_api_url,
|
||||
comment_html_url,
|
||||
own_login,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
format_state_change_notification(notif, &meta, own_login)
|
||||
}
|
||||
|
|
@ -454,16 +469,28 @@ fn build_meta_suffix(subject: Option<&serde_json::Value>, is_pr: bool, reason: &
|
|||
.and_then(|s| s["requested_reviewers"].as_array())
|
||||
.map(|arr| arr.iter().filter_map(|r| r["login"].as_str()).collect())
|
||||
.unwrap_or_default();
|
||||
if reviewers.is_empty() { None } else { Some(format!("reviewer: {}", reviewers.join(", "))) }
|
||||
if reviewers.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(format!("reviewer: {}", reviewers.join(", ")))
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Always include reason so multiple notifications for the same event
|
||||
// (each with a different Forgejo reason) are distinguishable (closes #110).
|
||||
let reason_line = if reason.is_empty() { None } else { Some(format!("reason: {reason}")) };
|
||||
let reason_line = if reason.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(format!("reason: {reason}"))
|
||||
};
|
||||
let mut out = format!("\n{assignee_line}");
|
||||
if let Some(r) = reviewer_line { write!(out, "\n{r}").ok(); }
|
||||
if let Some(r) = reason_line { write!(out, "\n{r}").ok(); }
|
||||
if let Some(r) = reviewer_line {
|
||||
write!(out, "\n{r}").ok();
|
||||
}
|
||||
if let Some(r) = reason_line {
|
||||
write!(out, "\n{r}").ok();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
|
|
@ -504,9 +531,24 @@ async fn format_comment_notification(
|
|||
.and_then(|c| c["state"].as_str())
|
||||
.and_then(review_state_label);
|
||||
|
||||
let url = if comment_html_url.is_empty() { meta.html_url } else { comment_html_url };
|
||||
let author = if actor_login.is_empty() { "?" } else { actor_login };
|
||||
let NotifMeta { title, notif_type, num, repo, meta_suffix, .. } = meta;
|
||||
let url = if comment_html_url.is_empty() {
|
||||
meta.html_url
|
||||
} else {
|
||||
comment_html_url
|
||||
};
|
||||
let author = if actor_login.is_empty() {
|
||||
"?"
|
||||
} else {
|
||||
actor_login
|
||||
};
|
||||
let NotifMeta {
|
||||
title,
|
||||
notif_type,
|
||||
num,
|
||||
repo,
|
||||
meta_suffix,
|
||||
..
|
||||
} = meta;
|
||||
|
||||
// Truncate the raw body first so the mention-overflow diff compares
|
||||
// like-for-like (escape_md_headings rewrites `# foo` to `\# foo`, so
|
||||
|
|
@ -574,7 +616,17 @@ fn format_state_change_notification(
|
|||
return None;
|
||||
}
|
||||
|
||||
let NotifMeta { title, notif_type, html_url, num, repo, meta_suffix, reason: _, subject, is_pr } = meta;
|
||||
let NotifMeta {
|
||||
title,
|
||||
notif_type,
|
||||
html_url,
|
||||
num,
|
||||
repo,
|
||||
meta_suffix,
|
||||
reason: _,
|
||||
subject,
|
||||
is_pr,
|
||||
} = meta;
|
||||
let label = notif_type_label(notif_type);
|
||||
let kind = match notif_state {
|
||||
"merged" => format!("{label} merged{num}{repo}"),
|
||||
|
|
@ -630,6 +682,12 @@ fn format_state_change_notification(
|
|||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[allow(
|
||||
clippy::too_many_lines,
|
||||
reason = "single-pass notification poll loop — split would obscure the \
|
||||
sequential 'fetch / classify / dispatch' rhythm and add helper \
|
||||
functions for state shared across all three phases"
|
||||
)]
|
||||
async fn poll_once(
|
||||
client: &reqwest::Client,
|
||||
forge_url: &str,
|
||||
|
|
@ -672,10 +730,15 @@ async fn poll_once(
|
|||
return;
|
||||
}
|
||||
|
||||
debug!(count = notifications.len(), "forge_notify: delivering notifications");
|
||||
debug!(
|
||||
count = notifications.len(),
|
||||
"forge_notify: delivering notifications"
|
||||
);
|
||||
|
||||
for notif in ¬ifications {
|
||||
let Some(id) = notif["id"].as_u64() else { continue };
|
||||
let Some(id) = notif["id"].as_u64() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Reason drop-list: suppress noisy reasons (subscribed/participating).
|
||||
// null/unknown reasons pass through — directed signals are never
|
||||
|
|
@ -733,28 +796,30 @@ async fn poll_once(
|
|||
// when HIVE_FORGE_KEEP_SUBSCRIPTIONS=1 — triage and other firehose
|
||||
// consumers set this to retain broad repo visibility.
|
||||
let reason = notif["reason"].as_str().unwrap_or("");
|
||||
if !keep_subscriptions && reason == "subscribed"
|
||||
if !keep_subscriptions
|
||||
&& reason == "subscribed"
|
||||
&& let Some(repo) = notif["repository"]["full_name"].as_str()
|
||||
&& !unsubbed_repos.contains(repo) {
|
||||
let unsub_url = format!("{forge_url}/api/v1/repos/{repo}/subscription");
|
||||
match client
|
||||
.delete(&unsub_url)
|
||||
.header("Authorization", format!("token {token}"))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) if r.status().is_success() || r.status().as_u16() == 404 => {
|
||||
debug!(%repo, "forge_notify: unsubscribed from repo watch");
|
||||
unsubbed_repos.insert(repo.to_owned());
|
||||
}
|
||||
Ok(r) => {
|
||||
debug!(%repo, status = %r.status(), "forge_notify: unsub non-2xx (ignored)");
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(%repo, error = ?e, "forge_notify: unsub request failed (ignored)");
|
||||
}
|
||||
}
|
||||
&& !unsubbed_repos.contains(repo)
|
||||
{
|
||||
let unsub_url = format!("{forge_url}/api/v1/repos/{repo}/subscription");
|
||||
match client
|
||||
.delete(&unsub_url)
|
||||
.header("Authorization", format!("token {token}"))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) if r.status().is_success() || r.status().as_u16() == 404 => {
|
||||
debug!(%repo, "forge_notify: unsubscribed from repo watch");
|
||||
unsubbed_repos.insert(repo.to_owned());
|
||||
}
|
||||
Ok(r) => {
|
||||
debug!(%repo, status = %r.status(), "forge_notify: unsub non-2xx (ignored)");
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(%repo, error = ?e, "forge_notify: unsub request failed (ignored)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ pub enum SocketReply {
|
|||
Recent(Vec<hive_sh4re::InboxRow>),
|
||||
Logs(String),
|
||||
/// `list_schedules` result — used by the manager surface only;
|
||||
/// AgentResponse has no equivalent variant.
|
||||
/// `AgentResponse` has no equivalent variant.
|
||||
Schedules(Vec<hive_sh4re::WireSchedule>),
|
||||
LooseEnds(Vec<hive_sh4re::LooseEnd>),
|
||||
PendingRemindersCount(u64),
|
||||
|
|
@ -185,8 +185,7 @@ pub fn format_recv(resp: Result<SocketReply, anyhow::Error>) -> String {
|
|||
/// resurfaced by `RequeueInflight` on this session's boot. Same
|
||||
/// string surfaces in the wake prompt (see the bin loops) and the
|
||||
/// in-turn `recv` tool result so claude sees the warning either way.
|
||||
pub const REDELIVERY_HINT: &str =
|
||||
"[redelivered after harness restart — may already be handled]\n";
|
||||
pub const REDELIVERY_HINT: &str = "[redelivered after harness restart — may already be handled]\n";
|
||||
|
||||
/// Format helper for `get_loose_ends`: renders a short bulleted list
|
||||
/// of pending approvals + questions + reminders. Empty list collapses
|
||||
|
|
@ -307,17 +306,32 @@ pub fn format_agent_meta(resp: Result<SocketReply, anyhow::Error>) -> String {
|
|||
match status_text {
|
||||
None => out.push_str("\nstatus: <none>"),
|
||||
Some(s) => {
|
||||
use std::fmt::Write as _;
|
||||
let age = status_set_at.and_then(|ts| {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()?
|
||||
.as_secs();
|
||||
let secs = now.saturating_sub(ts as u64);
|
||||
// `ts` is a unix epoch second the agent itself
|
||||
// sourced from `SystemTime` — always positive
|
||||
// in normal operation. Clamp the negative
|
||||
// (clock-skew) edge to 0 before the unsigned
|
||||
// cast so the cast loses no real precision.
|
||||
let ts_secs = u64::try_from(ts).unwrap_or(0);
|
||||
let secs = now.saturating_sub(ts_secs);
|
||||
Some(format_age_secs(secs))
|
||||
});
|
||||
// `write!` into the buffer instead of `push_str(&format!(…))` —
|
||||
// avoids the intermediate allocation clippy::format_push_string
|
||||
// flags. The infallible `String` writer makes this safe to
|
||||
// `let _ =`-ignore.
|
||||
match age {
|
||||
Some(a) => out.push_str(&format!("\nstatus: {s} (set {a} ago)")),
|
||||
None => out.push_str(&format!("\nstatus: {s}")),
|
||||
Some(a) => {
|
||||
let _ = write!(out, "\nstatus: {s} (set {a} ago)");
|
||||
}
|
||||
None => {
|
||||
let _ = write!(out, "\nstatus: {s}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -626,9 +640,13 @@ impl AgentServer {
|
|||
)]
|
||||
async fn set_status(&self, Parameters(args): Parameters<SetStatusArgs>) -> String {
|
||||
run_tool_envelope("set_status", args.text.clone(), async move {
|
||||
let (resp, retries) =
|
||||
self.dispatch(hive_sh4re::AgentRequest::SetStatus { text: args.text }).await;
|
||||
annotate_retries(format_ack(resp, "set_status", "status updated".to_owned()), retries)
|
||||
let (resp, retries) = self
|
||||
.dispatch(hive_sh4re::AgentRequest::SetStatus { text: args.text })
|
||||
.await;
|
||||
annotate_retries(
|
||||
format_ack(resp, "set_status", "status updated".to_owned()),
|
||||
retries,
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
|
@ -644,10 +662,7 @@ impl AgentServer {
|
|||
where the system-prompt label could be stale. Status reads `<none>` when the \
|
||||
target has never called `set_status` or has cleared it."
|
||||
)]
|
||||
async fn get_agent_meta(
|
||||
&self,
|
||||
Parameters(args): Parameters<GetAgentMetaArgs>,
|
||||
) -> String {
|
||||
async fn get_agent_meta(&self, Parameters(args): Parameters<GetAgentMetaArgs>) -> String {
|
||||
let log = args.name.clone().unwrap_or_else(|| "<self>".to_owned());
|
||||
run_tool_envelope("get_agent_meta", log, async move {
|
||||
let (resp, retries) = self
|
||||
|
|
@ -679,7 +694,11 @@ impl AgentServer {
|
|||
.dispatch(hive_sh4re::AgentRequest::CancelLooseEnd { kind, id })
|
||||
.await;
|
||||
annotate_retries(
|
||||
format_ack(resp, "cancel_loose_end", format!("cancelled {kind_label} {id}")),
|
||||
format_ack(
|
||||
resp,
|
||||
"cancel_loose_end",
|
||||
format!("cancelled {kind_label} {id}"),
|
||||
),
|
||||
retries,
|
||||
)
|
||||
})
|
||||
|
|
@ -721,7 +740,10 @@ impl AgentServer {
|
|||
file_path: args.file_path,
|
||||
})
|
||||
.await;
|
||||
annotate_retries(format_ack(resp, "remind", "reminder scheduled".to_string()), retries)
|
||||
annotate_retries(
|
||||
format_ack(resp, "remind", "reminder scheduled".to_string()),
|
||||
retries,
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
|
@ -949,7 +971,7 @@ pub struct RequestSchedulePromptArgs {
|
|||
/// `None` / absent = one-shot. `Some(n > 0)` = recurring every
|
||||
/// `n` seconds. The worker clamps catch-up so a long downtime
|
||||
/// fires ONCE on resume (skipped-cycle count surfaces in the
|
||||
/// per-target last_result), not N delayed pulses in a row.
|
||||
/// per-target `last_result`), not N delayed pulses in a row.
|
||||
#[serde(default)]
|
||||
pub interval_seconds: Option<u64>,
|
||||
/// Optional description shown on the dashboard approval card +
|
||||
|
|
@ -1301,10 +1323,7 @@ impl ManagerServer {
|
|||
Authorization mirrors `cancel_schedule`: you can fire your own schedules + any \
|
||||
owned by a sub-agent in your subtree per topology.json."
|
||||
)]
|
||||
async fn fire_schedule_now(
|
||||
&self,
|
||||
Parameters(args): Parameters<FireScheduleNowArgs>,
|
||||
) -> String {
|
||||
async fn fire_schedule_now(&self, Parameters(args): Parameters<FireScheduleNowArgs>) -> String {
|
||||
let log = format!("{args:?}");
|
||||
run_tool_envelope("fire_schedule_now", log, async move {
|
||||
let id = args.id;
|
||||
|
|
@ -1337,7 +1356,10 @@ impl ManagerServer {
|
|||
targets: args.targets,
|
||||
})
|
||||
.await;
|
||||
annotate_retries(format_ack(resp, "cancel_schedule", format!("cancelled #{id}")), retries)
|
||||
annotate_retries(
|
||||
format_ack(resp, "cancel_schedule", format!("cancelled #{id}")),
|
||||
retries,
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
|
@ -1394,7 +1416,9 @@ impl ManagerServer {
|
|||
)]
|
||||
async fn list_schedules(&self) -> String {
|
||||
run_tool_envelope("list_schedules", String::new(), async move {
|
||||
let (resp, retries) = self.dispatch(hive_sh4re::ManagerRequest::ListSchedules).await;
|
||||
let (resp, retries) = self
|
||||
.dispatch(hive_sh4re::ManagerRequest::ListSchedules)
|
||||
.await;
|
||||
let body = match resp {
|
||||
Ok(SocketReply::Schedules(schedules)) => serde_json::to_string(&schedules)
|
||||
.unwrap_or_else(|e| format!("list_schedules: serialise: {e:#}")),
|
||||
|
|
@ -1541,7 +1565,10 @@ impl ManagerServer {
|
|||
file_path: args.file_path,
|
||||
})
|
||||
.await;
|
||||
annotate_retries(format_ack(resp, "remind", "reminder scheduled".to_string()), retries)
|
||||
annotate_retries(
|
||||
format_ack(resp, "remind", "reminder scheduled".to_string()),
|
||||
retries,
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
|
@ -1573,9 +1600,13 @@ impl ManagerServer {
|
|||
)]
|
||||
async fn set_status(&self, Parameters(args): Parameters<SetStatusArgs>) -> String {
|
||||
run_tool_envelope("set_status", args.text.clone(), async move {
|
||||
let (resp, retries) =
|
||||
self.dispatch(hive_sh4re::ManagerRequest::SetStatus { text: args.text }).await;
|
||||
annotate_retries(format_ack(resp, "set_status", "status updated".to_owned()), retries)
|
||||
let (resp, retries) = self
|
||||
.dispatch(hive_sh4re::ManagerRequest::SetStatus { text: args.text })
|
||||
.await;
|
||||
annotate_retries(
|
||||
format_ack(resp, "set_status", "status updated".to_owned()),
|
||||
retries,
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
|
@ -1590,10 +1621,7 @@ impl ManagerServer {
|
|||
drift across renames. Status reads `<none>` when the target has never called \
|
||||
`set_status` or has cleared it."
|
||||
)]
|
||||
async fn get_agent_meta(
|
||||
&self,
|
||||
Parameters(args): Parameters<GetAgentMetaArgs>,
|
||||
) -> String {
|
||||
async fn get_agent_meta(&self, Parameters(args): Parameters<GetAgentMetaArgs>) -> String {
|
||||
let log = args.name.clone().unwrap_or_else(|| "<self>".to_owned());
|
||||
run_tool_envelope("get_agent_meta", log, async move {
|
||||
let (resp, retries) = self
|
||||
|
|
@ -1628,7 +1656,11 @@ impl ManagerServer {
|
|||
.dispatch(hive_sh4re::ManagerRequest::CancelLooseEnd { kind, id })
|
||||
.await;
|
||||
annotate_retries(
|
||||
format_ack(resp, "cancel_loose_end", format!("cancelled {kind_label} {id}")),
|
||||
format_ack(
|
||||
resp,
|
||||
"cancel_loose_end",
|
||||
format!("cancelled {kind_label} {id}"),
|
||||
),
|
||||
retries,
|
||||
)
|
||||
})
|
||||
|
|
@ -1800,10 +1832,7 @@ pub fn allowed_tools_arg(flavor: Flavor) -> String {
|
|||
if patterns.is_empty() {
|
||||
vec!["Bash".to_owned()]
|
||||
} else {
|
||||
patterns
|
||||
.into_iter()
|
||||
.map(|p| format!("Bash({p})"))
|
||||
.collect()
|
||||
patterns.into_iter().map(|p| format!("Bash({p})")).collect()
|
||||
}
|
||||
} else {
|
||||
vec![(*s).to_owned()]
|
||||
|
|
|
|||
|
|
@ -235,6 +235,7 @@ fn effective_context_window(bus: &Bus) -> u64 {
|
|||
/// Resolve the auto-reset watermark. Priority order:
|
||||
/// 1. `HIVE_AUTO_RESET_WATERMARK_TOKENS` env var (explicit override).
|
||||
/// 2. 50% of `effective_context_window(bus)`.
|
||||
///
|
||||
/// `0` disables auto-reset entirely.
|
||||
fn auto_reset_watermark_tokens(bus: &Bus) -> u64 {
|
||||
if let Some(v) = std::env::var("HIVE_AUTO_RESET_WATERMARK_TOKENS")
|
||||
|
|
@ -259,6 +260,7 @@ fn cache_ttl_secs() -> u64 {
|
|||
/// Resolve the proactive-compaction watermark. Priority order:
|
||||
/// 1. `HIVE_COMPACT_WATERMARK_TOKENS` env var (explicit override).
|
||||
/// 2. 75% of `effective_context_window(bus)`.
|
||||
///
|
||||
/// `0` disables proactive compaction (reactive path still applies).
|
||||
fn compact_watermark_tokens(bus: &Bus) -> u64 {
|
||||
if let Some(v) = std::env::var("HIVE_COMPACT_WATERMARK_TOKENS")
|
||||
|
|
@ -299,9 +301,7 @@ pub async fn drive_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutco
|
|||
// /compact itself would be absurd recursion; bubble it up as
|
||||
// a normal failure path.
|
||||
match compact_session(files, bus).await {
|
||||
TurnOutcome::Ok | TurnOutcome::Compacted => {
|
||||
run_turn(prompt, files, bus).await
|
||||
}
|
||||
TurnOutcome::Ok | TurnOutcome::Compacted => run_turn(prompt, files, bus).await,
|
||||
other => return other,
|
||||
}
|
||||
}
|
||||
|
|
@ -315,10 +315,9 @@ pub async fn drive_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutco
|
|||
// turn overflows into the reactive path. Best-effort — never changes
|
||||
// the outcome of the turn that already succeeded, but records it as
|
||||
// `Compacted` so turn stats can distinguish it from a plain `Ok`.
|
||||
if matches!(outcome, TurnOutcome::Ok)
|
||||
&& maybe_checkpoint_and_compact(files, bus).await {
|
||||
return TurnOutcome::Compacted;
|
||||
}
|
||||
if matches!(outcome, TurnOutcome::Ok) && maybe_checkpoint_and_compact(files, bus).await {
|
||||
return TurnOutcome::Compacted;
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
|
|
@ -707,53 +706,48 @@ async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<(bool,
|
|||
if AUTH_FAIL_MARKERS.iter().any(|m| line.contains(m)) {
|
||||
auth_out.store(true, Ordering::Relaxed);
|
||||
}
|
||||
match serde_json::from_str::<serde_json::Value>(&line) {
|
||||
Ok(v) => {
|
||||
// Rate-limit detection: only fire on JSON `error` events,
|
||||
// not on arbitrary text content. An agent discussing a past
|
||||
// rate limit in its response would otherwise trigger a false
|
||||
// positive (the full conversation flows through stdout as
|
||||
// stream-json, so any text the model outputs is visible here).
|
||||
if v.get("type").and_then(|t| t.as_str()) == Some("error") {
|
||||
let raw = v.to_string();
|
||||
if RATE_LIMIT_MARKERS.iter().any(|m| raw.contains(m)) {
|
||||
rate_out.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
if let Some(u) = crate::events::TokenUsage::from_assistant_event(&v) {
|
||||
last_inference = Some(u);
|
||||
}
|
||||
if let Some(cost) = crate::events::TokenUsage::from_stream_event(&v) {
|
||||
// Fallback to `cost` if the turn somehow produced
|
||||
// a result without any assistant event — keeps the
|
||||
// ctx badge from going stale on a degenerate turn.
|
||||
let ctx = last_inference.unwrap_or(cost);
|
||||
bus_out.record_turn_usage(ctx, cost);
|
||||
}
|
||||
// Seed the API-reported context-window from the result
|
||||
// event's `modelUsage.*.contextWindow` field. This is
|
||||
// the authoritative per-inference active window used for
|
||||
// compaction watermarks — it reflects what the model
|
||||
// actually enforces, which may differ from the Nix
|
||||
// config (e.g. 200k active window on a 1M cache model).
|
||||
if let Some(w) =
|
||||
crate::events::TokenUsage::context_window_from_result_event(&v)
|
||||
{
|
||||
bus_out.set_api_context_window(w);
|
||||
}
|
||||
bus_out.observe_stream(&v);
|
||||
bus_out.emit(LiveEvent::Stream(v));
|
||||
}
|
||||
Err(_) => {
|
||||
// Non-JSON stdout: raw text check is fine here since these
|
||||
// are claude CLI messages, not conversation content.
|
||||
if RATE_LIMIT_MARKERS.iter().any(|m| line.contains(m)) {
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) {
|
||||
// Rate-limit detection: only fire on JSON `error` events,
|
||||
// not on arbitrary text content. An agent discussing a past
|
||||
// rate limit in its response would otherwise trigger a false
|
||||
// positive (the full conversation flows through stdout as
|
||||
// stream-json, so any text the model outputs is visible here).
|
||||
if v.get("type").and_then(|t| t.as_str()) == Some("error") {
|
||||
let raw = v.to_string();
|
||||
if RATE_LIMIT_MARKERS.iter().any(|m| raw.contains(m)) {
|
||||
rate_out.store(true, Ordering::Relaxed);
|
||||
}
|
||||
bus_out.emit(LiveEvent::Note {
|
||||
text: format!("(non-json) {line}"),
|
||||
});
|
||||
}
|
||||
if let Some(u) = crate::events::TokenUsage::from_assistant_event(&v) {
|
||||
last_inference = Some(u);
|
||||
}
|
||||
if let Some(cost) = crate::events::TokenUsage::from_stream_event(&v) {
|
||||
// Fallback to `cost` if the turn somehow produced
|
||||
// a result without any assistant event — keeps the
|
||||
// ctx badge from going stale on a degenerate turn.
|
||||
let ctx = last_inference.unwrap_or(cost);
|
||||
bus_out.record_turn_usage(ctx, cost);
|
||||
}
|
||||
// Seed the API-reported context-window from the result
|
||||
// event's `modelUsage.*.contextWindow` field. This is
|
||||
// the authoritative per-inference active window used for
|
||||
// compaction watermarks — it reflects what the model
|
||||
// actually enforces, which may differ from the Nix
|
||||
// config (e.g. 200k active window on a 1M cache model).
|
||||
if let Some(w) = crate::events::TokenUsage::context_window_from_result_event(&v) {
|
||||
bus_out.set_api_context_window(w);
|
||||
}
|
||||
bus_out.observe_stream(&v);
|
||||
bus_out.emit(LiveEvent::Stream(v));
|
||||
} else {
|
||||
// Non-JSON stdout: raw text check is fine here since these
|
||||
// are claude CLI messages, not conversation content.
|
||||
if RATE_LIMIT_MARKERS.iter().any(|m| line.contains(m)) {
|
||||
rate_out.store(true, Ordering::Relaxed);
|
||||
}
|
||||
bus_out.emit(LiveEvent::Note {
|
||||
text: format!("(non-json) {line}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue