Compare commits

...
Author SHA1 Message Date
damocles
12ed5da11a refactor(#1825): drop MANAGER_NAME special-cases in crash_watch + tombstones 2026-06-23 18:35:23 +02:00
atlas
6dcc1cb7e1 fix(reopen): add # Errors doc to run()
Address argus review on #1938's reopen verb: public fns returning Result
need a # Errors doc per the workspace convention. Document the patch_json
and print_json failure modes.
2026-06-23 18:35:19 +02:00
atlas
11fb2ac0fc hive-forge: add 'reopen' verb (pr reopen / issue reopen)
hive-forge had close but no reopen, so reopening required the non-obvious
workaround 'issue edit <n> --state open'. Add a reopen verb mirroring close
(PATCH state=open), wired into both 'pr reopen' (kind-checked PR) and
'issue reopen' (kind-checked issue). Updates docs/forge.md + the subcommand
enumerations.
2026-06-23 18:35:19 +02:00
iris
efb34ef677 fix(dashboard): drop stale manager destroy-hiding comment + vestigial block
Follow-up to the destroy-guard removal — the bootstrap/root container is
now destroyable end-to-end. The frontend already offered DESTR0Y/PURG3
for every container; the only manager-gating left was a stale doc-comment
('destroy/purge hidden for the manager') and a vestigial bare block
wrapping the menu appends. Drop both (no behaviour change), and update the
dashboard.md menu doc: 'disabled for the bootstrap container' -> available
for any container (hive-c0re recreates it on next startup if destroyed).
2026-06-23 18:33:45 +02:00
damocles
9ee5a94d50 fix(#1930): headersCredential str type (no nix-store copy) + route traces to otlp 2026-06-23 17:46:41 +02:00
damocles
97ef00742e feat(#1930): optional per-agent OTEL stats export via claude-code telemetry 2026-06-23 17:46:41 +02:00
damocles
4c53898382 fix(#1940): carry pr_number on the live approval_added event 2026-06-23 17:28:01 +02:00
damocles
6516d4282e fix(#1932): hivectl start restores only the previously-running agents 2026-06-23 17:25:15 +02:00
iris
d1125207b4 fix(#1940): render merge_config_pr approval cards
renderApprovals had no merge_config_pr case, so those approvals
mis-rendered as generic spawn cards (spawn chip, no sha, 'container will
be created' body). Add a dedicated branch:
- distinct glyph + 'merge-pr' kind chip
- show the reviewed PR-head sha (sha_short), like apply_commit
- a 'review PR on forge' link built from pr_number (now on ApprovalView),
  gated on forge_present, mirroring the apply_commit 'commit on forge' link
- no config-diff side-panel (apply_commit-only for now)
History rows get the 'merge-pr' chip too.
2026-06-23 17:24:03 +02:00
damocles
00f682991c refactor(#1896): remove dead forge_notify reason plumbing + fix docs 2026-06-23 17:23:06 +02:00
19 changed files with 357 additions and 127 deletions

View file

@ -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. Two filter rules drop them silently
(mark-read without delivery):
loop claude on its own writes. The comment/review case is dropped
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: ...\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<body excerpt>\nassignee: ...\nreason: subscribed` |
| Later activity (open, not creation) | `[activity on PR #N owner/repo] title\nurl: ...\n\n<body excerpt>\nassignee: ...\nreason: subscribed` |
| State change | `[PR merged #N owner/repo] title\nurl: ...\nassignee: ...\nreason: subscribed` |
| 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<body excerpt>\nassignee: ...` |
| Later activity (open, not creation) | `[activity on PR #N owner/repo] title\nurl: ...\n\n<body excerpt>\nassignee: ...` |
| State change | `[PR merged #N owner/repo] title\nurl: ...\nassignee: ...` |
Review labels come from the Forgejo `state` field: `APPROVED`
`approved`, `REQUEST_CHANGES``changes requested`, `COMMENT`
@ -222,14 +222,6 @@ Every wrapper ends with one or more of:
the line shape is stable.
- `reviewer: <list>` — PR notifications only, present only when
`requested_reviewers` is non-empty.
- `reason: <forgejo-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

View file

@ -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/labels/assign/timeline as applicable).
comments/close/reopen/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,7 +27,9 @@ 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) ---

View file

@ -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`sub-agents only (disabled if the root/bootstrap container selected)
- `DESTR0Y` / `PURG3`always available
- `⇡ 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

View file

@ -314,17 +314,18 @@ 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 ? '→' : '⊕'),
el('span', { class: 'glyph' }, isApply ? '→' : isMergePr ? '⇒' : '⊕'),
el('span', { class: 'id' }, '#' + a.id),
el('span', { class: 'agent' }, a.agent),
el('span', { class: 'kind' + (isApply ? '' : ' kind-spawn') },
isApply ? 'apply' : isInit ? 'init' : 'spawn'),
el('span', { class: 'kind' + ((isApply || isMergePr) ? '' : ' kind-spawn') },
isApply ? 'apply' : isMergePr ? 'merge-pr' : isInit ? 'init' : 'spawn'),
);
if (isApply && a.sha_short) head.append(el('code', {}, a.sha_short));
if ((isApply || isMergePr) && 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).
@ -357,6 +358,19 @@ 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
@ -403,7 +417,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 === 'init_config' ? 'init' : 'spawn'), ' ',
el('span', { class: 'kind' }, a.kind === 'apply_commit' ? 'apply' : a.kind === 'merge_config_pr' ? 'merge-pr' : a.kind === 'init_config' ? 'init' : 'spawn'), ' ',
);
if (a.sha_short) row.append(el('code', {}, a.sha_short), ' ');
row.append(

View file

@ -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,
// destroy/purge hidden for the manager.
// restart/stop only shown when running, start only shown when
// stopped; rebuild + destroy/purge always shown.
// The button is CSS-invisible until the row is hovered (or menu is
// open) so it doesn't clutter quiet rows.
@ -383,20 +383,18 @@ 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' });

View file

@ -382,8 +382,7 @@ async fn format_notification(
};
let is_pr = matches!(notif_type, "Pull Request" | "Pull");
let reason = notif["reason"].as_str().unwrap_or("");
let meta_suffix = build_meta_suffix(subject.as_ref(), is_pr, reason);
let meta_suffix = build_meta_suffix(subject.as_ref(), is_pr);
// Determine whether this notification was triggered by a comment/review or
// by creation/state-change of the subject itself.
@ -396,7 +395,6 @@ async fn format_notification(
num,
repo,
meta_suffix,
reason,
subject,
is_pr,
};
@ -411,7 +409,7 @@ async fn format_notification(
)
.await
} else {
format_state_change_notification(notif, &meta, own_login)
Some(format_state_change_notification(notif, &meta, own_login))
}
}
@ -423,19 +421,15 @@ 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<serde_json::Value>,
is_pr: bool,
}
/// 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 {
/// 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 {
let assignees: Vec<&str> = subject
.and_then(|s| s["assignees"].as_array())
.map(|arr| arr.iter().filter_map(|a| a["login"].as_str()).collect())
@ -459,21 +453,10 @@ fn build_meta_suffix(subject: Option<&serde_json::Value>, is_pr: bool, reason: &
} 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
}
@ -574,7 +557,7 @@ fn format_state_change_notification(
notif: &serde_json::Value,
meta: &NotifMeta<'_>,
own_login: &str,
) -> Option<String> {
) -> String {
// Classification uses notif["subject"]["state"] directly — Forgejo
// returns "open" / "closed" / "merged" here. We do NOT rely on
// fetching the PR/issue detail for `merged`:
@ -583,14 +566,9 @@ fn format_state_change_notification(
// - Forgejo API type is "Pull" / "Issue", never "Pull Request".
let notif_state = notif["subject"]["state"].as_str().unwrap_or("");
// 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.
// "New" = the subject is open (or state is absent). Used below for
// the review-request override.
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,
@ -599,7 +577,6 @@ fn format_state_change_notification(
num,
repo,
meta_suffix,
reason: _,
subject,
is_pr,
} = meta;
@ -658,7 +635,7 @@ fn format_state_change_notification(
let mut out = format!("[{kind}] {title}\nurl: {html_url}{body_block}");
out.push_str(meta_suffix);
Some(out)
out
}
/// Decide whether a state-change notification represents the subject's

View file

@ -131,6 +131,15 @@ 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<HashSet<String>>,
/// 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<Option<Vec<String>>>,
/// 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
@ -380,6 +389,20 @@ pub struct ApprovalResolved<'a> {
pub description: Option<String>,
}
/// 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<String>,
pub diff: Option<String>,
pub description: Option<String>,
pub pr_number: Option<u64>,
}
/// Field-named payload for [`Coordinator::emit_question_added`].
/// Mirrors the `QuestionAdded` dashboard-event fields; all references
/// share the caller's lifetime.
@ -457,6 +480,7 @@ 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),
@ -730,15 +754,16 @@ 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,
id: i64,
agent: &str,
approval_kind: &'static str,
sha_short: Option<String>,
diff: Option<String>,
description: Option<String>,
) {
pub fn emit_approval_added(&self, ev: ApprovalAdded<'_>) {
let ApprovalAdded {
id,
agent,
approval_kind,
sha_short,
diff,
description,
pr_number,
} = ev;
self.emit_dashboard_event(DashboardEvent::ApprovalAdded {
seq: self.next_seq(),
id,
@ -747,6 +772,7 @@ impl Coordinator {
sha_short,
diff,
description,
pr_number,
});
}
@ -1157,6 +1183,20 @@ 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<String>) {
*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<Vec<String>> {
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.

View file

@ -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, MANAGER_NAME};
use crate::lifecycle::{self, AGENT_PREFIX};
const POLL_INTERVAL: Duration = Duration::from_secs(10);
@ -37,15 +37,11 @@ pub fn spawn(coord: Arc<Coordinator>) {
continue;
};
let logical = logical.to_owned();
if logical != MANAGER_NAME {
sub_agents.push(logical.clone());
}
sub_agents.push(logical.clone());
if lifecycle::is_running(&logical).await {
current_running.insert(logical.clone());
}
if logical != MANAGER_NAME
&& claude_has_session(&Coordinator::agent_claude_dir(&logical))
{
if claude_has_session(&Coordinator::agent_claude_dir(&logical)) {
current_logged_in.insert(logical.clone());
}
}

View file

@ -979,9 +979,8 @@ async fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
out
}
/// 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.
/// 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.
fn build_tombstone_views(
coord: &Coordinator,
containers: &[ContainerView],
@ -995,7 +994,7 @@ fn build_tombstone_views(
.collect();
Coordinator::kept_state_names()
.into_iter()
.filter(|name| name != MANAGER_NAME && !live.contains(name.as_str()))
.filter(|name| !live.contains(name.as_str()))
.map(|name| {
let root = Coordinator::agent_state_root(&name);
let state_bytes = dir_size_bytes(&root);
@ -1663,7 +1662,15 @@ async fn post_request_spawn(
// refetch. Spawn approvals carry no diff/sha.
state
.coord
.emit_approval_added(id, &name, "spawn", None, None, None);
.emit_approval_added(crate::coordinator::ApprovalAdded {
id,
agent: &name,
approval_kind: "spawn",
sha_short: None,
diff: None,
description: None,
pr_number: None,
});
(StatusCode::OK, "ok").into_response()
}
Err(e) => error_response(&format!("request-spawn {name} failed: {e:#}")),

View file

@ -76,6 +76,13 @@ pub enum DashboardEvent {
sha_short: Option<String>,
diff: Option<String>,
description: Option<String>,
/// 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<u64>,
},
/// A pending approval transitioned to a terminal state
/// (approved / denied / failed). Clients move the row out of the
@ -346,6 +353,7 @@ mod tests {
sha_short: None,
diff: None,
description: None,
pr_number: None,
},
DashboardEvent::ApprovalResolved {
seq: 1,

View file

@ -101,10 +101,32 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> 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 agents = scoped_agents(scope).await?;
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 infra = scoped_infra(scope);
handle_start(&agents, &infra).await?
}
@ -328,10 +350,19 @@ async fn handle_start(agents: &[String], infra: &[InfraContainer]) -> Result<Hos
/// container (from `lifecycle::list`) when `agents` is set or the scope is
/// "everything", plus any explicit `agent_names`. Returns de-duplicated
/// logical names with the `h-` container prefix stripped.
/// A scope that targets *every* agent rather than an explicit
/// `--agent <name>` 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<Vec<String>> {
use std::collections::BTreeSet;
let mut set: BTreeSet<String> = BTreeSet::new();
if scope.agents || scope.is_everything() {
if is_broad_scope(scope) {
for c in lifecycle::list().await? {
let logical = c
.strip_prefix(lifecycle::AGENT_PREFIX)

View file

@ -1438,14 +1438,15 @@ fn handle_request_update_meta_inputs(
}
};
tracing::info!(%id, %label, "update_meta_inputs approval queued");
coord.emit_approval_added(
coord.emit_approval_added(crate::coordinator::ApprovalAdded {
id,
requester,
"update_meta_inputs",
None,
None,
description.map(str::to_owned),
);
agent: requester,
approval_kind: "update_meta_inputs",
sha_short: None,
diff: None,
description: description.map(str::to_owned),
pr_number: None,
});
AgentResponse::Ok
}
@ -1542,14 +1543,15 @@ fn handle_request_schedule_prompt(
interval = ?payload.interval_seconds,
"schedule_prompt approval queued"
);
coord.emit_approval_added(
coord.emit_approval_added(crate::coordinator::ApprovalAdded {
id,
requester,
"schedule_prompt",
None,
None,
payload.description.clone(),
);
agent: requester,
approval_kind: "schedule_prompt",
sha_short: None,
diff: None,
description: payload.description.clone(),
pr_number: None,
});
AgentResponse::Ok
}
@ -1802,7 +1804,15 @@ 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(id, name, "init_config", None, None, description);
coord.emit_approval_added(crate::coordinator::ApprovalAdded {
id,
agent: name,
approval_kind: "init_config",
sha_short: None,
diff: None,
description,
pr_number: None,
});
Ok(id)
}
@ -1928,14 +1938,15 @@ 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(
coord.emit_approval_added(crate::coordinator::ApprovalAdded {
id,
agent,
"apply_commit",
Some(sha_short),
Some(diff),
description.map(str::to_owned),
);
approval_kind: "apply_commit",
sha_short: Some(sha_short),
diff: Some(diff),
description: description.map(str::to_owned),
pr_number: None,
});
Ok((id, sha))
}

View file

@ -315,8 +315,9 @@ pub fn apply_add_child(
}
/// Reconcile `topology.json` against the current agent set. Adds an
/// entry (default: parent = manager, manager itself = root) for any
/// agent missing from the file; removes entries for agents no longer
/// 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
/// 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.

View file

@ -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 <show|create|edit|view|comment|comments|close|labels|assign|timeline> …`.
/// Issue-scoped commands: `issue <show|create|edit|view|comment|comments|close|reopen|labels|assign|timeline> …`.
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 <show|status|create|merge|reviews|commits|diff|view|comment|comments|close|labels|assign|timeline> …`.
/// PR-scoped commands: `pr <show|status|create|merge|reviews|commits|diff|view|comment|comments|close|reopen|labels|assign|timeline> …`.
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

View file

@ -1,7 +1,7 @@
//! `issue <verb>` — 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/labels/assign/timeline) kind-check the
//! PRs (view/comment/comments/close/reopen/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,6 +34,8 @@ 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.
@ -65,6 +67,10 @@ 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)

View file

@ -32,6 +32,7 @@ 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;

View file

@ -1,7 +1,7 @@
//! `pr <verb>` — 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/labels/assign/timeline) kind-check the number
//! (view/comment/comments/close/reopen/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,6 +42,8 @@ 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.
@ -77,6 +79,10 @@ 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)

View file

@ -0,0 +1,35 @@
//! `reopen <number>` — 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/<n>`
//! 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"),
}))
}

View file

@ -174,6 +174,71 @@ 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 <token>`). 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 = [ ];
@ -722,6 +787,11 @@ 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
@ -1640,6 +1710,37 @@ 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";
@ -1662,9 +1763,10 @@ 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 = "${pkgs.hyperhive}/bin/${binary} serve";
ExecStart = if otel.enable then "${otelExecStart}" else "${pkgs.hyperhive}/bin/${binary} serve";
Restart = "on-failure";
RestartSec = 2;
# Per-service runtime dir owned by `User=` below; the harness
@ -1674,6 +1776,9 @@ in
RuntimeDirectory = "hive-config";
User = userName;
Group = userName;
}
// lib.optionalAttrs (otel.enable && otel.headersCredential != null) {
LoadCredential = [ "otel-headers:${otel.headersCredential}" ];
};
};