diff --git a/docs/forge.md b/docs/forge.md index 527ca5ac..a2dfd793 100644 --- a/docs/forge.md +++ b/docs/forge.md @@ -129,16 +129,16 @@ best-effort: logged at debug/warn and retried next tick. Forgejo fires notifications for the agent's own actions (it opened a PR, posted a comment, submitted a review). Surfacing those would -loop claude on its own writes. The comment/review case is dropped -silently (mark-read without delivery): +loop claude on its own writes. Two filter rules drop them silently +(mark-read without delivery): +- **Self-authored new items** — notifications with + `reason == "author"` AND subject state `open` (or missing). State + transitions (merge / close) on the agent's own PRs DO surface, + since those are triggered by someone else. - **Self-authored comments / reviews** — comment payload's `user.login` matches `own_login`. -Self-authored *new items* (an agent opening its own PR/issue) are not -filtered and do surface — the notification subject carries no author -field to match against without a per-notification fetch. - `own_login` is fetched once at startup via `GET /api/v1/user`. On fetch failure the filter degrades open (no filtering) rather than crashing the task — a noisy inbox beats a silently-stuck poller. @@ -181,11 +181,11 @@ Five shapes, distinguished by the notification's classification: | Trigger | Wrapper | | --- | --- | -| Comment on issue / PR | `[comment on PR #N owner/repo] title\nurl: ...\n\nauthor: body\nassignee: ...` | -| Review submission | `[PR approved #N owner/repo] title\nurl: ...\n\nreviewer: body\nassignee: ...` | -| New issue / PR | `[new PR #N owner/repo] title\nurl: ...\n\n\nassignee: ...` | -| Later activity (open, not creation) | `[activity on PR #N owner/repo] title\nurl: ...\n\n\nassignee: ...` | -| State change | `[PR merged #N owner/repo] title\nurl: ...\nassignee: ...` | +| Comment on issue / PR | `[comment on PR #N owner/repo] title\nurl: ...\n\nauthor: body\nassignee: ...\nreason: mention` | +| Review submission | `[PR approved #N owner/repo] title\nurl: ...\n\nreviewer: body\nassignee: ...\nreason: review_requested` | +| New issue / PR | `[new PR #N owner/repo] title\nurl: ...\n\n\nassignee: ...\nreason: subscribed` | +| Later activity (open, not creation) | `[activity on PR #N owner/repo] title\nurl: ...\n\n\nassignee: ...\nreason: subscribed` | +| State change | `[PR merged #N owner/repo] title\nurl: ...\nassignee: ...\nreason: subscribed` | Review labels come from the Forgejo `state` field: `APPROVED` → `approved`, `REQUEST_CHANGES` → `changes requested`, `COMMENT` → @@ -222,6 +222,14 @@ Every wrapper ends with one or more of: the line shape is stable. - `reviewer: ` — PR notifications only, present only when `requested_reviewers` is non-empty. +- `reason: ` — always present when the notification + carries a reason; absent when the field is null/missing. + +The `reason` line distinguishes otherwise-identical messages: Forgejo +emits one notification per applicable reason for the same event +(e.g. both `mention` and `subscribed` arrive for a PR comment that +tags the agent). Without the suffix, the agent would see duplicated +wrapper text with no signal which Forgejo path triggered each copy. ### Review-request override diff --git a/docs/tools/forge.md b/docs/tools/forge.md index 2e4fdd53..4240360d 100644 --- a/docs/tools/forge.md +++ b/docs/tools/forge.md @@ -19,7 +19,7 @@ under `issue` and `pr` parent commands — `hive-forge pr close 42`, refuses an issue number, which the old generic `close` couldn't). Run `hive-forge pr --help` / `hive-forge issue --help` for the full subcommand list (show/create/edit/status/merge/reviews/commits/diff/view/comment/ -comments/close/reopen/labels/assign/timeline as applicable). +comments/close/labels/assign/timeline as applicable). The flat forms below (`close 42`, `pr-create …`, `pr-status …`, …) still work as **hidden back-compat aliases** during the transition and are dropped from @@ -27,9 +27,7 @@ as **hidden back-compat aliases** during the transition and are dropped from ```bash hive-forge pr close 42 # close a PR (kind-validated) -hive-forge pr reopen 42 # reopen a closed PR (kind-validated) hive-forge issue close 42 # close an issue (kind-validated) -hive-forge issue reopen 42 # reopen a closed issue (kind-validated) hive-forge pr status --pr 42 # PR health (mergeable / CI / reviews) hive-forge issue create --title "..." --body "..." # --- flat aliases below remain valid (hidden) --- diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 78fb338d..3d34ba73 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -925,7 +925,7 @@ frosted-mauve bar slides up from the bottom of the viewport - `■ ST0P` — running agents only - `▶ ST4RT` — stopped agents only - `↻ R3BU1LD` — always available - - `DESTR0Y` / `PURG3` — always available + - `DESTR0Y` / `PURG3` — sub-agents only (disabled if the root/bootstrap container selected) - `⇡ M0V3 → ROOT` — promote selected agents to top-level (parent = null); disabled when all selected are already at root. Backend `topology::set_parent` refuses moves it can't satisfy diff --git a/frontend/packages/dashboard/src/call.js b/frontend/packages/dashboard/src/call.js index 6c0965a9..967f89c4 100644 --- a/frontend/packages/dashboard/src/call.js +++ b/frontend/packages/dashboard/src/call.js @@ -314,18 +314,17 @@ export function renderApprovals() { for (const a of pending) { const isApply = a.kind === 'apply_commit'; const isInit = a.kind === 'init_config'; - const isMergePr = a.kind === 'merge_config_pr'; const li = el('li', { class: 'approval-card' }); // ── identity header ────────────────────────────────────────── const head = el('div', { class: 'approval-head' }, - el('span', { class: 'glyph' }, isApply ? '→' : isMergePr ? '⇒' : '⊕'), + el('span', { class: 'glyph' }, isApply ? '→' : '⊕'), el('span', { class: 'id' }, '#' + a.id), el('span', { class: 'agent' }, a.agent), - el('span', { class: 'kind' + ((isApply || isMergePr) ? '' : ' kind-spawn') }, - isApply ? 'apply' : isMergePr ? 'merge-pr' : isInit ? 'init' : 'spawn'), + el('span', { class: 'kind' + (isApply ? '' : ' kind-spawn') }, + isApply ? 'apply' : isInit ? 'init' : 'spawn'), ); - if ((isApply || isMergePr) && a.sha_short) head.append(el('code', {}, a.sha_short)); + if (isApply && a.sha_short) head.append(el('code', {}, a.sha_short)); // When the approval was requested — relative time, right-aligned. // Goes amber once it's been pending an hour so a stale request is // obvious at a glance (see docs/web-ui.md::Approval card). @@ -358,19 +357,6 @@ export function renderApprovals() { }, '↳ commit on forge ↗')); } body.append(drill); - } else if (isMergePr) { - // PR-based config deploy: link to the reviewed PR on the forge - // (mirrors the apply_commit "commit on forge" link). The config - // diff side-panel is apply_commit-only for now. - const drill = el('div', { class: 'drill-ins' }); - if (forgeBase && a.pr_number != null) { - drill.append(el('a', { - class: 'panel-trigger', target: '_blank', rel: 'noopener', - href: `${forgeBase}/agent-configs/${a.agent}/pulls/${a.pr_number}`, - title: 'review this config PR on the hive forge', - }, '↳ review PR on forge ↗')); - } - body.append(drill); } else { body.append(el('span', { class: 'meta' }, isInit @@ -417,7 +403,7 @@ function renderApprovalHistory(root, history) { el('span', { class: 'glyph glyph-' + a.status }, glyph), ' ', el('span', { class: 'id' }, '#' + a.id), ' ', el('span', { class: 'agent' }, a.agent), ' ', - el('span', { class: 'kind' }, a.kind === 'apply_commit' ? 'apply' : a.kind === 'merge_config_pr' ? 'merge-pr' : a.kind === 'init_config' ? 'init' : 'spawn'), ' ', + el('span', { class: 'kind' }, a.kind === 'apply_commit' ? 'apply' : a.kind === 'init_config' ? 'init' : 'spawn'), ' ', ); if (a.sha_short) row.append(el('code', {}, a.sha_short), ' '); row.append( diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index 8b9f4c5a..bfa66b06 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -249,8 +249,8 @@ window.marked = marked; // ─── per-agent context menu ────────────────────────────────────────── // Three-dot (⋮) button on each agent card for quick single-agent // lifecycle actions without needing to select first. State-aware: - // restart/stop only shown when running, start only shown when - // stopped; rebuild + destroy/purge always shown. + // restart/stop only shown when running, start only shown when stopped, + // destroy/purge hidden for the manager. // The button is CSS-invisible until the row is hovered (or menu is // open) so it doesn't clutter quiet rows. @@ -383,18 +383,20 @@ window.marked = marked; `view ${c.name} journal logs`), ); - dropdown.append( - menuSep(), - menuItem('DESTR0Y', { - action: '/api/destroy/', - confirm: `destroy ${c.name}? container removed; state + creds kept.`, - }), - menuItem('PURG3', { - action: '/api/destroy/', - body: { purge: 'on' }, - confirm: `PURGE ${c.name}? WIPES container, config history, claude creds, and notes. no undo.`, - }), - ); + { + dropdown.append( + menuSep(), + menuItem('DESTR0Y', { + action: '/api/destroy/', + confirm: `destroy ${c.name}? container removed; state + creds kept.`, + }), + menuItem('PURG3', { + action: '/api/destroy/', + body: { purge: 'on' }, + confirm: `PURGE ${c.name}? WIPES container, config history, claude creds, and notes. no undo.`, + }), + ); + } if (c.deployed_sha && forgeBase) { const li = el('li', { role: 'presentation' }); diff --git a/hive-ag3nt/src/forge_notify.rs b/hive-ag3nt/src/forge_notify.rs index 4a8c5b26..b56b4c7d 100644 --- a/hive-ag3nt/src/forge_notify.rs +++ b/hive-ag3nt/src/forge_notify.rs @@ -382,7 +382,8 @@ async fn format_notification( }; let is_pr = matches!(notif_type, "Pull Request" | "Pull"); - let meta_suffix = build_meta_suffix(subject.as_ref(), is_pr); + let reason = notif["reason"].as_str().unwrap_or(""); + let meta_suffix = build_meta_suffix(subject.as_ref(), is_pr, reason); // Determine whether this notification was triggered by a comment/review or // by creation/state-change of the subject itself. @@ -395,6 +396,7 @@ async fn format_notification( num, repo, meta_suffix, + reason, subject, is_pr, }; @@ -409,7 +411,7 @@ async fn format_notification( ) .await } else { - Some(format_state_change_notification(notif, &meta, own_login)) + format_state_change_notification(notif, &meta, own_login) } } @@ -421,15 +423,19 @@ struct NotifMeta<'a> { num: String, repo: String, meta_suffix: String, + /// Forgejo `reason` value (e.g. "mention", "assigned", "subscribed"). + /// Appended to every wrapper as the `reason:` line in the meta + /// suffix (see `docs/forge.md::Meta suffix`). + reason: &'a str, /// Fetched subject detail (issue/PR JSON); used for review-request detection. subject: Option, is_pr: bool, } -/// Build the `\nassignee: ...` (and optionally `\nreviewer: ...`) -/// suffix appended to every wrapper. Shape + presence rules live in -/// `docs/forge.md::Meta suffix`. -fn build_meta_suffix(subject: Option<&serde_json::Value>, is_pr: bool) -> String { +/// Build the `\nassignee: ...` (and optionally `\nreviewer: ...` and +/// `\nreason: ...`) suffix appended to every wrapper. Shape + +/// presence rules live in `docs/forge.md::Meta suffix`. +fn build_meta_suffix(subject: Option<&serde_json::Value>, is_pr: bool, reason: &str) -> String { let assignees: Vec<&str> = subject .and_then(|s| s["assignees"].as_array()) .map(|arr| arr.iter().filter_map(|a| a["login"].as_str()).collect()) @@ -453,10 +459,21 @@ fn build_meta_suffix(subject: Option<&serde_json::Value>, is_pr: bool) -> String } else { None }; + // Always include reason so multiple notifications for the same + // event (each with a different Forgejo reason) stay + // distinguishable. + 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(); + } out } @@ -557,7 +574,7 @@ fn format_state_change_notification( notif: &serde_json::Value, meta: &NotifMeta<'_>, own_login: &str, -) -> String { +) -> Option { // Classification uses notif["subject"]["state"] directly — Forgejo // returns "open" / "closed" / "merged" here. We do NOT rely on // fetching the PR/issue detail for `merged`: @@ -566,9 +583,14 @@ fn format_state_change_notification( // - Forgejo API type is "Pull" / "Issue", never "Pull Request". let notif_state = notif["subject"]["state"].as_str().unwrap_or(""); - // "New" = the subject is open (or state is absent). Used below for - // the review-request override. + // Self-notification filter: drop new items we authored ourselves + // (`reason == "author"` + open state). State transitions on our + // own PRs (merge / close) come from someone else, so those stay. let is_new = notif_state == "open" || notif_state.is_empty(); + if is_new && meta.reason == "author" && !own_login.is_empty() { + debug!(%own_login, "forge_notify: skipping self-authored new item"); + return None; + } let NotifMeta { title, @@ -577,6 +599,7 @@ fn format_state_change_notification( num, repo, meta_suffix, + reason: _, subject, is_pr, } = meta; @@ -635,7 +658,7 @@ fn format_state_change_notification( let mut out = format!("[{kind}] {title}\nurl: {html_url}{body_block}"); out.push_str(meta_suffix); - out + Some(out) } /// Decide whether a state-change notification represents the subject's diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index a4591049..383e2a23 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -131,15 +131,6 @@ pub struct Coordinator { /// agent is in this set — the inbound fence. Cleared when the agent /// reports `GracefulStopComplete` or the container is stopped. graceful_stop_pending: Mutex>, - /// Logical agent names that were running at the last broad-scope - /// `hivectl stop`. A subsequent broad-scope `hivectl start` restores - /// only this set (intersected with the requested scope) rather than - /// every configured container, so agents the operator intentionally - /// left stopped stay stopped. `None` when no broad stop has happened - /// since the last start (or since daemon boot) — start then falls - /// back to "start all". In-daemon memory only (hive-c0re survives - /// `hivectl stop`); host-reboot persistence is a separate follow-up. - last_stopped_running: Mutex>>, /// Unified wire-facing event channel feeding the dashboard SSE /// stream. Carries broker messages (mirrored from `broker.subscribe` /// by the forwarder task in `main.rs`) and dashboard-only mutation @@ -389,20 +380,6 @@ pub struct ApprovalResolved<'a> { pub description: Option, } -/// Field-named payload for [`Coordinator::emit_approval_added`]. -/// Mirrors the `ApprovalAdded` dashboard-event fields. `agent` -/// borrows from the caller; `approval_kind` is a compile-time -/// constant. `pr_number` is set for `merge_config_pr` only. -pub struct ApprovalAdded<'a> { - pub id: i64, - pub agent: &'a str, - pub approval_kind: &'static str, - pub sha_short: Option, - pub diff: Option, - pub description: Option, - pub pr_number: Option, -} - /// Field-named payload for [`Coordinator::emit_question_added`]. /// Mirrors the `QuestionAdded` dashboard-event fields; all references /// share the caller's lifetime. @@ -480,7 +457,6 @@ impl Coordinator { recent_transient: Mutex::new(HashMap::new()), recent_crashes: Mutex::new(HashMap::new()), graceful_stop_pending: Mutex::new(HashSet::new()), - last_stopped_running: Mutex::new(None), dashboard_events, event_seq: AtomicU64::new(0), meta_updates_active: AtomicU64::new(0), @@ -754,16 +730,15 @@ impl Coordinator { /// Emit `ApprovalAdded` immediately after the row is inserted in /// sqlite. Caller passes the diff text it already computed (or /// `None` for spawn approvals which carry no diff). - pub fn emit_approval_added(&self, ev: ApprovalAdded<'_>) { - let ApprovalAdded { - id, - agent, - approval_kind, - sha_short, - diff, - description, - pr_number, - } = ev; + pub fn emit_approval_added( + &self, + id: i64, + agent: &str, + approval_kind: &'static str, + sha_short: Option, + diff: Option, + description: Option, + ) { self.emit_dashboard_event(DashboardEvent::ApprovalAdded { seq: self.next_seq(), id, @@ -772,7 +747,6 @@ impl Coordinator { sha_short, diff, description, - pr_number, }); } @@ -1183,20 +1157,6 @@ impl Coordinator { self.graceful_stop_pending.lock().unwrap().remove(name); } - /// Record the set of agents that were running at a broad-scope - /// `hivectl stop`, so the next broad-scope `start` restores exactly - /// this set. See the `last_stopped_running` field doc. - pub fn set_last_stopped_running(&self, agents: Vec) { - *self.last_stopped_running.lock().unwrap() = Some(agents); - } - - /// Take (and clear) the recorded broad-stop running set, if any. A - /// broad-scope `start` uses this to restore only the previously - /// running agents; `None` means "no record — start all". - pub fn take_last_stopped_running(&self) -> Option> { - self.last_stopped_running.lock().unwrap().take() - } - /// Set of agents whose transient was cleared within the last /// `grace` seconds — i.e. agents the operator just acted on, /// whose stop the crash watcher should NOT classify as a crash. diff --git a/hive-c0re/src/crash_watch.rs b/hive-c0re/src/crash_watch.rs index e5ebacdf..c7cca394 100644 --- a/hive-c0re/src/crash_watch.rs +++ b/hive-c0re/src/crash_watch.rs @@ -9,7 +9,7 @@ use std::time::Duration; use crate::container_view::claude_has_session; use crate::coordinator::{Coordinator, TransientKind}; -use crate::lifecycle::{self, AGENT_PREFIX}; +use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME}; const POLL_INTERVAL: Duration = Duration::from_secs(10); @@ -37,11 +37,15 @@ pub fn spawn(coord: Arc) { continue; }; let logical = logical.to_owned(); - sub_agents.push(logical.clone()); + if logical != MANAGER_NAME { + sub_agents.push(logical.clone()); + } if lifecycle::is_running(&logical).await { current_running.insert(logical.clone()); } - if claude_has_session(&Coordinator::agent_claude_dir(&logical)) { + if logical != MANAGER_NAME + && claude_has_session(&Coordinator::agent_claude_dir(&logical)) + { current_logged_in.insert(logical.clone()); } } diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 5b4c6e90..a321b31e 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -979,8 +979,9 @@ async fn build_approval_views(approvals: Vec) -> Vec { out } -/// State-dir names that don't appear in the live container list. Each -/// one surfaces in the dashboard as a row with R3V1V3 + PURG3 actions. +/// State-dir names that don't appear in the live container list (and +/// aren't the manager). Each one surfaces in the dashboard as a row +/// with R3V1V3 + PURG3 actions. fn build_tombstone_views( coord: &Coordinator, containers: &[ContainerView], @@ -994,7 +995,7 @@ fn build_tombstone_views( .collect(); Coordinator::kept_state_names() .into_iter() - .filter(|name| !live.contains(name.as_str())) + .filter(|name| name != MANAGER_NAME && !live.contains(name.as_str())) .map(|name| { let root = Coordinator::agent_state_root(&name); let state_bytes = dir_size_bytes(&root); @@ -1662,15 +1663,7 @@ async fn post_request_spawn( // refetch. Spawn approvals carry no diff/sha. state .coord - .emit_approval_added(crate::coordinator::ApprovalAdded { - id, - agent: &name, - approval_kind: "spawn", - sha_short: None, - diff: None, - description: None, - pr_number: None, - }); + .emit_approval_added(id, &name, "spawn", None, None, None); (StatusCode::OK, "ok").into_response() } Err(e) => error_response(&format!("request-spawn {name} failed: {e:#}")), diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index 13307bcb..7cf02437 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -76,13 +76,6 @@ pub enum DashboardEvent { sha_short: Option, diff: Option, description: Option, - /// Forge PR number, for `merge_config_pr` approvals only — lets - /// the live `applyApprovalAdded` path build the "review PR on - /// forge" link without waiting for a cold `/api/state` refresh - /// (mirrors `ApprovalView::pr_number`). `None` for every other - /// kind. - #[serde(skip_serializing_if = "Option::is_none")] - pr_number: Option, }, /// A pending approval transitioned to a terminal state /// (approved / denied / failed). Clients move the row out of the @@ -353,7 +346,6 @@ mod tests { sha_short: None, diff: None, description: None, - pr_number: None, }, DashboardEvent::ApprovalResolved { seq: 1, diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index b774253e..2248cb90 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -101,32 +101,10 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { // graceful-stop queue, to re-expand it). let agents = scoped_agents(scope).await?; let infra = scoped_infra(scope); - // On a broad stop, remember which agents were actually - // running so a later broad `start` restores only those - // (not every configured container). A targeted `--agent` - // stop must not redefine the restore set. - if is_broad_scope(scope) { - let mut running = Vec::new(); - for a in &agents { - if lifecycle::is_running(a).await { - running.push(a.clone()); - } - } - coord.set_last_stopped_running(running); - } handle_stop(&coord, &agents, &infra, *graceful).await? } HostRequest::Start { scope } => { - let mut agents = scoped_agents(scope).await?; - // A broad start restores only the set recorded at the - // last broad stop, if any. No record (cold "bring the - // hive up", or a daemon restart since the stop) → start - // all. Targeted `--agent` start is never filtered. - if is_broad_scope(scope) - && let Some(prev) = coord.take_last_stopped_running() - { - agents.retain(|a| prev.contains(a)); - } + let agents = scoped_agents(scope).await?; let infra = scoped_infra(scope); handle_start(&agents, &infra).await? } @@ -350,19 +328,10 @@ async fn handle_start(agents: &[String], infra: &[InfraContainer]) -> Result` list: either the `agents` flag or a bare -/// "everything" scope. Broad scopes are the ones whose stop/start pair -/// drives the previously-running restore set (see `handle` Stop/Start -/// arms); a targeted `--agent` stop/start must not redefine it. -fn is_broad_scope(scope: &LifecycleScope) -> bool { - scope.agents || scope.is_everything() -} - async fn scoped_agents(scope: &LifecycleScope) -> Result> { use std::collections::BTreeSet; let mut set: BTreeSet = BTreeSet::new(); - if is_broad_scope(scope) { + if scope.agents || scope.is_everything() { for c in lifecycle::list().await? { let logical = c .strip_prefix(lifecycle::AGENT_PREFIX) diff --git a/hive-c0re/src/socket_server.rs b/hive-c0re/src/socket_server.rs index f26abe62..d7722f4e 100644 --- a/hive-c0re/src/socket_server.rs +++ b/hive-c0re/src/socket_server.rs @@ -1438,15 +1438,14 @@ fn handle_request_update_meta_inputs( } }; tracing::info!(%id, %label, "update_meta_inputs approval queued"); - coord.emit_approval_added(crate::coordinator::ApprovalAdded { + coord.emit_approval_added( id, - agent: requester, - approval_kind: "update_meta_inputs", - sha_short: None, - diff: None, - description: description.map(str::to_owned), - pr_number: None, - }); + requester, + "update_meta_inputs", + None, + None, + description.map(str::to_owned), + ); AgentResponse::Ok } @@ -1543,15 +1542,14 @@ fn handle_request_schedule_prompt( interval = ?payload.interval_seconds, "schedule_prompt approval queued" ); - coord.emit_approval_added(crate::coordinator::ApprovalAdded { + coord.emit_approval_added( id, - agent: requester, - approval_kind: "schedule_prompt", - sha_short: None, - diff: None, - description: payload.description.clone(), - pr_number: None, - }); + requester, + "schedule_prompt", + None, + None, + payload.description.clone(), + ); AgentResponse::Ok } @@ -1804,15 +1802,7 @@ pub(crate) fn submit_init_config( ) .map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?; tracing::info!(%id, %name, "init_config approval queued"); - coord.emit_approval_added(crate::coordinator::ApprovalAdded { - id, - agent: name, - approval_kind: "init_config", - sha_short: None, - diff: None, - description, - pr_number: None, - }); + coord.emit_approval_added(id, name, "init_config", None, None, description); Ok(id) } @@ -1938,15 +1928,14 @@ pub(crate) async fn submit_apply_commit( // get a fully-formed row without a snapshot refetch. `sha_short` // is reused from the dedup gate above. let diff = crate::dashboard::approval_diff(agent, id).await; - coord.emit_approval_added(crate::coordinator::ApprovalAdded { + coord.emit_approval_added( id, agent, - approval_kind: "apply_commit", - sha_short: Some(sha_short), - diff: Some(diff), - description: description.map(str::to_owned), - pr_number: None, - }); + "apply_commit", + Some(sha_short), + Some(diff), + description.map(str::to_owned), + ); Ok((id, sha)) } diff --git a/hive-c0re/src/topology.rs b/hive-c0re/src/topology.rs index 19c6c278..ce2f879d 100644 --- a/hive-c0re/src/topology.rs +++ b/hive-c0re/src/topology.rs @@ -315,9 +315,8 @@ pub fn apply_add_child( } /// Reconcile `topology.json` against the current agent set. Adds an -/// entry (default: parent = null — a new agent with no declared parent -/// is its own root) for any agent missing from the file; removes -/// entries for agents no longer +/// entry (default: parent = manager, manager itself = root) for any +/// agent missing from the file; removes entries for agents no longer /// present. Existing entries are preserved as-is — operator/manager /// choices stick across regenerations. Returns true when the file /// changed and should be re-committed by the caller. diff --git a/hive-forge/src/main.rs b/hive-forge/src/main.rs index a733be23..6735f718 100644 --- a/hive-forge/src/main.rs +++ b/hive-forge/src/main.rs @@ -55,7 +55,7 @@ enum Verb { /// Dump title + body + all comments for an issue or PR. #[command(hide = true)] View(verbs::view::Args), - /// Issue-scoped commands: `issue …`. + /// Issue-scoped commands: `issue …`. Issue(verbs::issue_cmd::Args), /// Create an issue. Prints the issue URL on success. #[command(hide = true)] @@ -63,7 +63,7 @@ enum Verb { /// Edit an issue's title, body, state, or milestone. #[command(hide = true)] IssueEdit(verbs::issue_edit::Args), - /// PR-scoped commands: `pr …`. + /// PR-scoped commands: `pr …`. Pr(verbs::pr_cmd::Args), /// List a PR's commits as JSON (sha, message, author date, author). /// Survives rebase-rewritten shas — message + author date let a diff --git a/hive-forge/src/verbs/issue_cmd.rs b/hive-forge/src/verbs/issue_cmd.rs index 886f0611..993cbaeb 100644 --- a/hive-forge/src/verbs/issue_cmd.rs +++ b/hive-forge/src/verbs/issue_cmd.rs @@ -1,7 +1,7 @@ //! `issue ` — issue-scoped sub-commands. Wraps the per-verb modules //! under an `issue` parent so `hive-forge issue close 42`, `issue create …`, //! etc. read as kind-namespaced commands. The generic verbs that also work on -//! PRs (view/comment/comments/close/reopen/labels/assign/timeline) kind-check the +//! PRs (view/comment/comments/close/labels/assign/timeline) kind-check the //! number is an issue first (`assert_kind`); the issue-only verbs are //! kind-correct by construction. The flat `issue-*` + bare generic verbs stay //! as hidden back-compat aliases (see `main.rs`). @@ -34,8 +34,6 @@ enum Cmd { Comments(verbs::comments::Args), /// Close the issue. Close(verbs::close::Args), - /// Reopen a closed issue. - Reopen(verbs::reopen::Args), /// List / add / remove labels. Labels(verbs::labels::Args), /// Assign or unassign a user. @@ -67,10 +65,6 @@ pub fn run(client: &Client, args: Args) -> Result<()> { assert_kind(client, a.number, Kind::Issue)?; verbs::close::run(client, a) } - Cmd::Reopen(a) => { - assert_kind(client, a.number, Kind::Issue)?; - verbs::reopen::run(client, a) - } Cmd::Labels(a) => { assert_kind(client, a.number, Kind::Issue)?; verbs::labels::run(client, a) diff --git a/hive-forge/src/verbs/mod.rs b/hive-forge/src/verbs/mod.rs index 60b4bb99..0e2bc60c 100644 --- a/hive-forge/src/verbs/mod.rs +++ b/hive-forge/src/verbs/mod.rs @@ -32,7 +32,6 @@ pub mod pr_create; pub mod pr_merge; pub mod pr_reviews; pub mod pr_status; -pub mod reopen; pub mod repo_add_collaborator; pub mod repo_create; pub mod repo_labels; diff --git a/hive-forge/src/verbs/pr_cmd.rs b/hive-forge/src/verbs/pr_cmd.rs index 533100f3..ea380166 100644 --- a/hive-forge/src/verbs/pr_cmd.rs +++ b/hive-forge/src/verbs/pr_cmd.rs @@ -1,7 +1,7 @@ //! `pr ` — PR-scoped sub-commands. Wraps the per-verb modules under a //! `pr` parent so `hive-forge pr close 42`, `pr status --pr 42`, etc. read as //! kind-namespaced commands. The generic verbs that also work on issues -//! (view/comment/comments/close/reopen/labels/assign/timeline) kind-check the number +//! (view/comment/comments/close/labels/assign/timeline) kind-check the number //! is a PR first (`assert_kind`); the PR-only verbs hit `/pulls/…` and are //! kind-correct by construction. The flat `pr-*` + bare generic verbs stay as //! hidden back-compat aliases (see `main.rs`). @@ -42,8 +42,6 @@ enum Cmd { Comments(verbs::comments::Args), /// Close the PR. Close(verbs::close::Args), - /// Reopen a closed PR. - Reopen(verbs::reopen::Args), /// List / add / remove labels. Labels(verbs::labels::Args), /// Assign or unassign a user. @@ -79,10 +77,6 @@ pub fn run(client: &Client, args: Args) -> Result<()> { assert_kind(client, a.number, Kind::Pr)?; verbs::close::run(client, a) } - Cmd::Reopen(a) => { - assert_kind(client, a.number, Kind::Pr)?; - verbs::reopen::run(client, a) - } Cmd::Labels(a) => { assert_kind(client, a.number, Kind::Pr)?; verbs::labels::run(client, a) diff --git a/hive-forge/src/verbs/reopen.rs b/hive-forge/src/verbs/reopen.rs deleted file mode 100644 index 68208375..00000000 --- a/hive-forge/src/verbs/reopen.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! `reopen ` — reopen a closed issue or PR. -//! -//! Mirror of `close`: sends a PATCH setting the issue/PR `state` back to -//! `open`. PRs share the issue number space, so the same `/issues/` -//! endpoint reopens either. - -use anyhow::Result; -use clap::Args as ClapArgs; -use serde_json::json; - -use crate::client::Client; -use crate::verbs::print_json; - -#[derive(ClapArgs)] -pub struct Args { - /// Issue or PR number. - pub(crate) number: u64, -} - -/// # Errors -/// -/// Returns an error when the `PATCH /repos/{repo}/issues/{number}` request -/// fails (network / non-success status from `patch_json`) or when emitting -/// the JSON summary via `print_json` fails. -pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); - let resp = client.patch_json( - &format!("/repos/{repo}/issues/{}", args.number), - &json!({ "state": "open" }), - )?; - print_json(&json!({ - "number": resp.get("number"), - "state": resp.get("state"), - })) -} diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index dae0e750..ecb00ca8 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -174,71 +174,6 @@ in visible = false; }; - options.hyperhive.otel = { - enable = lib.mkEnableOption '' - exporting this agent's Claude Code stats (token usage, cost, tool - calls) to an OTLP endpoint via Claude Code's built-in OpenTelemetry. - Each agent's harness exports its own stats directly to the collector, - so it keeps working even when hive-c0re is down. Meant to be enabled - hive-wide (one switch for every agent) - there is no per-agent - opt-in flag beyond this option - ''; - - endpoint = lib.mkOption { - type = lib.types.str; - default = ""; - example = "https://collector.example.com/otel"; - description = '' - OTLP collector endpoint, set as `OTEL_EXPORTER_OTLP_ENDPOINT`. - Required when `enable` is true. - ''; - }; - - protocol = lib.mkOption { - type = lib.types.enum [ - "http/protobuf" - "http/json" - "grpc" - ]; - default = "http/protobuf"; - description = '' - OTLP wire protocol, set as `OTEL_EXPORTER_OTLP_PROTOCOL`. - ''; - }; - - headersCredential = lib.mkOption { - # `str`, not `path`: a `path`-typed *relative* literal (e.g. - # `./otel-headers`) is hash-copied into the world-readable nix store - # at eval time, which would defeat the whole point of this option. - # Keep it a string and require an absolute runtime path so the secret - # is only ever read from disk by systemd at start, never nix-stored. - type = lib.types.nullOr lib.types.str; - default = null; - example = "/run/secrets/otel-headers"; - description = '' - Absolute path to an operator-provided secret file whose contents - become `OTEL_EXPORTER_OTLP_HEADERS` (e.g. - `Authorization=Bearer `). Loaded via systemd - `LoadCredential` into the unit-private credential store at - runtime, so the token is never copied into the nix store or - exposed in the process argv. Must be an absolute path (systemd - `LoadCredential` requires one). Leave null if the endpoint needs - no auth header. - ''; - }; - - extraResourceAttributes = lib.mkOption { - type = lib.types.str; - default = ""; - example = "deployment.environment=prod"; - description = '' - Extra comma-separated entries appended to - `OTEL_RESOURCE_ATTRIBUTES` after the built-in - `service.name` / `agent` / `hive` / `swarm` labels. - ''; - }; - }; - options.hyperhive.allowedRecipients = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; @@ -787,11 +722,6 @@ in ''; assertions = [ - # OTEL export needs an endpoint to point at. - { - assertion = !config.hyperhive.otel.enable || config.hyperhive.otel.endpoint != ""; - message = "hyperhive.otel.enable is true but hyperhive.otel.endpoint is empty."; - } # Guard the inputs-routed-as-output pattern: the agent flake.nix is # expected to set `_module.args.flakeInputs = builtins.removeAttrs inputs ["self"]`. # If `self` leaks into flakeInputs the agent gets a spurious attrset @@ -1710,37 +1640,6 @@ in systemd.services.hive-ag3nt = let binary = "hive"; - otel = config.hyperhive.otel; - # Claude Code's native OpenTelemetry is env-driven; the harness - # spawns `claude` as a child which inherits this unit's env, so - # setting these here is all it takes to export per-agent stats. - otelEnv = lib.optionalAttrs otel.enable { - CLAUDE_CODE_ENABLE_TELEMETRY = "1"; - OTEL_METRICS_EXPORTER = "otlp"; - OTEL_LOGS_EXPORTER = "otlp"; - # Route traces to OTLP too so any spans Claude Code emits land - # at the configured collector rather than a default exporter. - OTEL_TRACES_EXPORTER = "otlp"; - OTEL_EXPORTER_OTLP_PROTOCOL = otel.protocol; - OTEL_EXPORTER_OTLP_ENDPOINT = otel.endpoint; - }; - # When OTEL is on, wrap the harness launch so (1) the bearer-token - # header is read from the systemd credential at start (never in the - # nix store or argv) and (2) the resource attributes are assembled - # from this agent's name (known at build time) plus the hive/swarm - # names (inherited HYPERHIVE_HIVE_NAME / HYPERHIVE_SWARM_NAME env, - # the same vars `identity::hive_name`/`swarm_name` read at runtime). - otelExecStart = pkgs.writeShellScript "hive-serve-otel" '' - set -eu - if [ -n "''${CREDENTIALS_DIRECTORY:-}" ] && [ -r "$CREDENTIALS_DIRECTORY/otel-headers" ]; then - OTEL_EXPORTER_OTLP_HEADERS="$(cat "$CREDENTIALS_DIRECTORY/otel-headers")" - export OTEL_EXPORTER_OTLP_HEADERS - fi - export OTEL_RESOURCE_ATTRIBUTES="service.name=hyperhive-agent,agent=${userName},hive=''${HYPERHIVE_HIVE_NAME:-unknown},swarm=''${HYPERHIVE_SWARM_NAME:-unknown}${ - lib.optionalString (otel.extraResourceAttributes != "") ",${otel.extraResourceAttributes}" - }" - exec ${pkgs.hyperhive}/bin/${binary} serve - ''; in { description = "${binary} harness"; @@ -1763,10 +1662,9 @@ in # `hive_c0re::agent_sockets::socket_path_for(name)` so lifecycle # bind-mounts and gateway upstream config stay in sync. HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock"; - } - // otelEnv; + }; serviceConfig = { - ExecStart = if otel.enable then "${otelExecStart}" else "${pkgs.hyperhive}/bin/${binary} serve"; + ExecStart = "${pkgs.hyperhive}/bin/${binary} serve"; Restart = "on-failure"; RestartSec = 2; # Per-service runtime dir owned by `User=` below; the harness @@ -1776,9 +1674,6 @@ in RuntimeDirectory = "hive-config"; User = userName; Group = userName; - } - // lib.optionalAttrs (otel.enable && otel.headersCredential != null) { - LoadCredential = [ "otel-headers:${otel.headersCredential}" ]; }; };