Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76533b0c20 | ||
|
|
799804e3d1 | ||
|
|
599a71254a | ||
|
|
9ab241dc24 | ||
|
|
502e9e0e70 | ||
|
|
8305c0716a | ||
|
|
c015f62764 | ||
|
|
1819bee43c | ||
|
|
0e17459943 |
17 changed files with 381 additions and 41 deletions
|
|
@ -154,7 +154,7 @@ hive-forge/ Forgejo CLI wrapper (`hive-forge` binary)
|
||||||
src/client.rs blocking reqwest client (Forgejo REST API)
|
src/client.rs blocking reqwest client (Forgejo REST API)
|
||||||
src/body.rs body input resolution (--body / --body-file / piped stdin)
|
src/body.rs body input resolution (--body / --body-file / piped stdin)
|
||||||
src/verbs/<verb>.rs one module per verb (view, issue, pr, comment,
|
src/verbs/<verb>.rs one module per verb (view, issue, pr, comment,
|
||||||
comment-show, comment-edit, issue-create,
|
comments, comment-show, comment-edit, issue-create,
|
||||||
issue-edit, pr-create, pr-reviews, assign,
|
issue-edit, pr-create, pr-reviews, assign,
|
||||||
close, labels, milestone, branches,
|
close, labels, milestone, branches,
|
||||||
tree-sha, diff, subscription, attach-issue,
|
tree-sha, diff, subscription, attach-issue,
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,8 @@ since #280). Use it instead of ad-hoc curl pipelines:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hive-forge view 42 # title + body + comments
|
hive-forge view 42 # title + body + comments
|
||||||
|
hive-forge comments 42 # list all comments (human-readable)
|
||||||
|
hive-forge comments 42 --json # list as JSON array
|
||||||
hive-forge comment 42 --body "..." # post comment (inline body)
|
hive-forge comment 42 --body "..." # post comment (inline body)
|
||||||
hive-forge comment 42 --body-file - <<EOF # ...or pipe a HEREDOC
|
hive-forge comment 42 --body-file - <<EOF # ...or pipe a HEREDOC
|
||||||
multi-line body
|
multi-line body
|
||||||
|
|
|
||||||
|
|
@ -482,9 +482,11 @@ Three fixed-position layers frame a full-viewport terminal:
|
||||||
glass — `backdrop-filter: blur` lets scrolled terminal rows show
|
glass — `backdrop-filter: blur` lets scrolled terminal rows show
|
||||||
through. Three flex columns (#394 redesign):
|
through. Three flex columns (#394 redesign):
|
||||||
|
|
||||||
- **Agent icon** (`<img class="agent-icon">`): full-height square
|
- **Agent icon** (`<img class="agent-icon">`): fixed-size square
|
||||||
identity anchor (6em, `height: 100%; aspect-ratio: 1`). Falls back
|
identity anchor (5em, `width: 5em; aspect-ratio: 1;
|
||||||
to the dimmed hyperhive mark on load error.
|
align-self: flex-start` — capped so a tall state-row doesn't
|
||||||
|
inflate the icon, #411). Falls back to the dimmed hyperhive mark
|
||||||
|
on load error.
|
||||||
- **Main column** (`.agent-header-main`): two rows.
|
- **Main column** (`.agent-header-main`): two rows.
|
||||||
- Row 1 (`.agent-header-title-row`): title (`<h2 id="title">`) +
|
- Row 1 (`.agent-header-title-row`): title (`<h2 id="title">`) +
|
||||||
meta-nav (`<nav id="meta-links">`). Meta-nav renders
|
meta-nav (`<nav id="meta-links">`). Meta-nav renders
|
||||||
|
|
|
||||||
|
|
@ -1828,6 +1828,63 @@ window.marked = marked;
|
||||||
NOTIF.bind();
|
NOTIF.bind();
|
||||||
Panel.bind();
|
Panel.bind();
|
||||||
|
|
||||||
|
// ─── live updates: dashboard event stream (#406 step 3) ────────────────
|
||||||
|
// The dashboard subscribes to /dashboard/stream for live mutation
|
||||||
|
// events so the SW4RM / Y3R C4LL / SYST3M panes update without an
|
||||||
|
// operator action triggering a refreshState. Pre-step-2 this wiring
|
||||||
|
// lived inside the broker-terminal IIFE which only fired on /flow.html
|
||||||
|
// — meaning /index.html only updated on cold load + after async-form
|
||||||
|
// submits.
|
||||||
|
//
|
||||||
|
// Bare `EventSource` (no terminal infrastructure needed — the
|
||||||
|
// dashboard doesn't render broker rows). Each event's `kind` is
|
||||||
|
// looked up against `MUTATION_HANDLERS`; unknown kinds (broker
|
||||||
|
// `sent` / `delivered`, anything new the backend adds) silently
|
||||||
|
// no-op. On (re)connect we kick a refreshState() to recover events
|
||||||
|
// lost during the disconnect window (same pattern as flow.js's
|
||||||
|
// onStreamOpen).
|
||||||
|
//
|
||||||
|
// `#408` will give /index.html its own stream that omits the
|
||||||
|
// broker traffic the dashboard never uses; for now both pages
|
||||||
|
// subscribe to `/dashboard/stream` and filter client-side.
|
||||||
|
const MUTATION_HANDLERS = {
|
||||||
|
approval_added: applyApprovalAdded,
|
||||||
|
approval_resolved: applyApprovalResolved,
|
||||||
|
question_added: applyQuestionAdded,
|
||||||
|
question_resolved: applyQuestionResolved,
|
||||||
|
transient_set: applyTransientSet,
|
||||||
|
transient_cleared: applyTransientCleared,
|
||||||
|
container_state_changed: applyContainerStateChanged,
|
||||||
|
container_removed: applyContainerRemoved,
|
||||||
|
tombstones_changed: applyTombstonesChanged,
|
||||||
|
meta_inputs_changed: applyMetaInputsChanged,
|
||||||
|
meta_update_running: applyMetaUpdateRunning,
|
||||||
|
rebuild_queue_changed: applyRebuildQueueChanged,
|
||||||
|
};
|
||||||
|
(function bindDashboardStream() {
|
||||||
|
const es = new EventSource('/dashboard/stream');
|
||||||
|
es.onmessage = (e) => {
|
||||||
|
let ev;
|
||||||
|
try { ev = JSON.parse(e.data); } catch { return; }
|
||||||
|
const h = MUTATION_HANDLERS[ev.kind];
|
||||||
|
if (!h) return; // broker rows + future kinds — dashboard doesn't care
|
||||||
|
try { h(ev); }
|
||||||
|
catch (err) { console.error('dashboard SSE handler', ev.kind, err); }
|
||||||
|
};
|
||||||
|
es.onopen = () => {
|
||||||
|
// Re-sync to recover events that fired during a disconnect
|
||||||
|
// window (issue #163). Initial connect also fires onopen — the
|
||||||
|
// first refreshState() above and this one race, but refreshState
|
||||||
|
// is idempotent so the second call just overwrites with the
|
||||||
|
// freshest snapshot. Cheap on a quiescent server, fine to repeat.
|
||||||
|
refreshState();
|
||||||
|
};
|
||||||
|
es.onerror = () => {
|
||||||
|
// EventSource auto-reconnects; nothing to do beyond logging.
|
||||||
|
console.debug('dashboard SSE error, will retry');
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
// ─── tab routing (#369) ────────────────────────────────────────────────
|
// ─── tab routing (#369) ────────────────────────────────────────────────
|
||||||
// Hash-based: `#swarm` / `#call` / `#system` activate the matching
|
// Hash-based: `#swarm` / `#call` / `#system` activate the matching
|
||||||
// pane on the dashboard. Empty hash defaults to SW4RM. FL0W is NOT
|
// pane on the dashboard. Empty hash defaults to SW4RM. FL0W is NOT
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,12 @@
|
||||||
elsewhere. */
|
elsewhere. */
|
||||||
|
|
||||||
body.dashboard-shell {
|
body.dashboard-shell {
|
||||||
/* Width is generous so the container tree + agent cards aren't
|
/* Full-width layout (#416 — mara: drop the 90em cap so wide screens
|
||||||
boxed too narrow — agent state pills want room. */
|
don't waste real estate on empty side margins). `padding: 0 1.5em
|
||||||
max-width: 90em;
|
1.5em` keeps a small gutter on the left/right so cards don't kiss
|
||||||
margin: 0 auto;
|
the viewport edge; `.dashboard-chrome { margin: 0 -1.5em ... }`
|
||||||
|
still pulls the chrome bar edge-to-edge through that gutter. */
|
||||||
|
margin: 0;
|
||||||
padding: 0 1.5em 1.5em;
|
padding: 0 1.5em 1.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,14 @@
|
||||||
// count=0); pages use it to set state flags from the replayed history.
|
// count=0); pages use it to set state flags from the replayed history.
|
||||||
|
|
||||||
const NEAR_BOTTOM_PX = 48;
|
const NEAR_BOTTOM_PX = 48;
|
||||||
|
// Snap-to-bottom animation duration (#400 + mara feedback). Browser
|
||||||
|
// default `scrollTo({ behavior: 'smooth' })` runs ~500ms, which read
|
||||||
|
// as "still smooth, but visibly slow." 140ms with ease-out is fast
|
||||||
|
// enough to feel snap-y, slow enough that the row's destination
|
||||||
|
// reads as motion (not a jump). Distances under SCROLL_SNAP_PX
|
||||||
|
// short-circuit to instant — animating a 12px nudge is just jitter.
|
||||||
|
const SCROLL_ANIM_MS = 140;
|
||||||
|
const SCROLL_SNAP_PX = 24;
|
||||||
|
|
||||||
export function create(opts) {
|
export function create(opts) {
|
||||||
const log = opts.logEl;
|
const log = opts.logEl;
|
||||||
|
|
@ -69,16 +77,77 @@ export function create(opts) {
|
||||||
// handler so both programmatic scrollTop assignments and
|
// handler so both programmatic scrollTop assignments and
|
||||||
// operator-driven wheel/drag stay in sync.
|
// operator-driven wheel/drag stay in sync.
|
||||||
let stickToBottom = true;
|
let stickToBottom = true;
|
||||||
|
// Guards scroll-event-handler from misreading the position while
|
||||||
|
// our own animation is mid-flight (#400). The animation drives
|
||||||
|
// scrollTop with rAF, which fires a stream of scroll events as
|
||||||
|
// the position eases toward the target — the position passes
|
||||||
|
// through "not near bottom" before settling. Without this gate,
|
||||||
|
// the scroll handler flips `stickToBottom` to false mid-animation,
|
||||||
|
// which then causes the MutationObserver to skip the next snap
|
||||||
|
// and leaves the operator stranded mid-scroll. Set to the
|
||||||
|
// animation's nominal end + small headroom; each fresh snap
|
||||||
|
// re-arms it so back-to-back snaps stay gated.
|
||||||
|
let smoothScrollingUntil = 0;
|
||||||
|
// rAF id for the current snap animation. Cancelled when a new
|
||||||
|
// snap starts so we never have two animations fighting over
|
||||||
|
// scrollTop.
|
||||||
|
let scrollAnimRaf = 0;
|
||||||
|
|
||||||
function isNearBottom() {
|
function isNearBottom() {
|
||||||
return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX;
|
return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX;
|
||||||
}
|
}
|
||||||
|
// Snap the log to the bottom with a brief eased animation
|
||||||
|
// (#400 + mara: snappier than the browser's default 500ms smooth
|
||||||
|
// scroll). Each call cancels the previous frame loop and starts a
|
||||||
|
// fresh one, so a burst of mutations coalesces into one ride to
|
||||||
|
// the latest bottom. Re-evaluates the target each frame so a
|
||||||
|
// renderer mutation landing mid-animation extends the destination
|
||||||
|
// without a visible jump. Falls back to instant scroll when
|
||||||
|
// `currentNoAnim` is true (backfill replay — operator never sees
|
||||||
|
// intermediate positions, animation is wasted frames) or when the
|
||||||
|
// remaining distance is under SCROLL_SNAP_PX.
|
||||||
|
function snapToBottom(immediate) {
|
||||||
|
stickToBottom = true;
|
||||||
|
if (scrollAnimRaf) {
|
||||||
|
cancelAnimationFrame(scrollAnimRaf);
|
||||||
|
scrollAnimRaf = 0;
|
||||||
|
}
|
||||||
|
const target = log.scrollHeight - log.clientHeight;
|
||||||
|
const start = log.scrollTop;
|
||||||
|
const distance = target - start;
|
||||||
|
if (immediate || currentNoAnim || distance <= SCROLL_SNAP_PX) {
|
||||||
|
smoothScrollingUntil = 0;
|
||||||
|
log.scrollTop = target;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
smoothScrollingUntil = Date.now() + SCROLL_ANIM_MS + 80;
|
||||||
|
const t0 = performance.now();
|
||||||
|
const easeOut = (t) => 1 - Math.pow(1 - t, 3);
|
||||||
|
const step = (now) => {
|
||||||
|
const elapsed = now - t0;
|
||||||
|
const frac = Math.min(1, elapsed / SCROLL_ANIM_MS);
|
||||||
|
// Re-read target each frame so mutations landing mid-animation
|
||||||
|
// (the common case — a renderer appended badge / body bits
|
||||||
|
// after api.row returned) extend the destination smoothly
|
||||||
|
// rather than landing short.
|
||||||
|
const currentTarget = log.scrollHeight - log.clientHeight;
|
||||||
|
log.scrollTop = start + (currentTarget - start) * easeOut(frac);
|
||||||
|
if (frac < 1) {
|
||||||
|
scrollAnimRaf = requestAnimationFrame(step);
|
||||||
|
} else {
|
||||||
|
// Final exact settle on the as-of-now bottom.
|
||||||
|
log.scrollTop = log.scrollHeight - log.clientHeight;
|
||||||
|
scrollAnimRaf = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
scrollAnimRaf = requestAnimationFrame(step);
|
||||||
|
}
|
||||||
function ensurePill() {
|
function ensurePill() {
|
||||||
if (pill) return pill;
|
if (pill) return pill;
|
||||||
pill = document.createElement('button');
|
pill = document.createElement('button');
|
||||||
pill.type = 'button';
|
pill.type = 'button';
|
||||||
pill.className = 'tail-pill';
|
pill.className = 'tail-pill';
|
||||||
pill.addEventListener('click', () => { log.scrollTop = log.scrollHeight; });
|
pill.addEventListener('click', () => snapToBottom());
|
||||||
pillAnchor.appendChild(pill);
|
pillAnchor.appendChild(pill);
|
||||||
return pill;
|
return pill;
|
||||||
}
|
}
|
||||||
|
|
@ -92,6 +161,12 @@ export function create(opts) {
|
||||||
pill.classList.add('visible');
|
pill.classList.add('visible');
|
||||||
}
|
}
|
||||||
log.addEventListener('scroll', () => {
|
log.addEventListener('scroll', () => {
|
||||||
|
// Mid-smooth-scroll: ignore the intermediate scroll events. The
|
||||||
|
// gate releases when the animation has had time to settle (or
|
||||||
|
// when the next snap re-arms it). Without this, easing toward
|
||||||
|
// bottom would flip `stickToBottom` false partway and the next
|
||||||
|
// MO callback would skip the snap.
|
||||||
|
if (Date.now() < smoothScrollingUntil) return;
|
||||||
stickToBottom = isNearBottom();
|
stickToBottom = isNearBottom();
|
||||||
if (stickToBottom) { unseen = 0; updatePill(); }
|
if (stickToBottom) { unseen = 0; updatePill(); }
|
||||||
});
|
});
|
||||||
|
|
@ -113,7 +188,7 @@ export function create(opts) {
|
||||||
// assignments don't re-trigger MO (the scroll itself isn't a
|
// assignments don't re-trigger MO (the scroll itself isn't a
|
||||||
// DOM mutation), so no feedback loop.
|
// DOM mutation), so no feedback loop.
|
||||||
const mo = new MutationObserver(() => {
|
const mo = new MutationObserver(() => {
|
||||||
if (stickToBottom) log.scrollTop = log.scrollHeight;
|
if (stickToBottom) snapToBottom();
|
||||||
});
|
});
|
||||||
mo.observe(log, { childList: true, subtree: true, characterData: true });
|
mo.observe(log, { childList: true, subtree: true, characterData: true });
|
||||||
|
|
||||||
|
|
@ -130,13 +205,7 @@ export function create(opts) {
|
||||||
// frame instead of one microtask + frame.)
|
// frame instead of one microtask + frame.)
|
||||||
function afterAppend(wasNearBottom) {
|
function afterAppend(wasNearBottom) {
|
||||||
if (currentNoAnim || wasNearBottom) {
|
if (currentNoAnim || wasNearBottom) {
|
||||||
// Re-arm stickToBottom before the scroll — the assignment
|
snapToBottom();
|
||||||
// fires a scroll event which sets it via isNearBottom(),
|
|
||||||
// but doing it eagerly here makes the next renderer
|
|
||||||
// mutation's MO callback deterministic even if the scroll
|
|
||||||
// event hasn't fired yet.
|
|
||||||
stickToBottom = true;
|
|
||||||
log.scrollTop = log.scrollHeight;
|
|
||||||
} else {
|
} else {
|
||||||
unseen += 1;
|
unseen += 1;
|
||||||
updatePill();
|
updatePill();
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ Claude session (OAuth credentials) lives at `/root/.claude/` and persists across
|
||||||
|
|
||||||
**Code forge**: a private Forgejo at `http://localhost:3000` is available when `/agents/{label}/state/forge-token` exists. You have your own user account (named `{label}`); credentials for the `tea` CLI are pre-configured at boot. Use `tea repos create`, `tea pulls create --base main --head <branch>`, `tea pulls list`, `tea issues create`, etc. for any persistent code work — git repos that should outlive a single turn, code you want a peer or the operator to review, anything you'd otherwise jam into `/shared`. Falls back to plain `git`/`curl` if `tea` doesn't fit; the REST API is at `http://localhost:3000/api/v1/` with the same token (`Authorization: token $(cat /agents/{label}/state/forge-token)`).
|
**Code forge**: a private Forgejo at `http://localhost:3000` is available when `/agents/{label}/state/forge-token` exists. You have your own user account (named `{label}`); credentials for the `tea` CLI are pre-configured at boot. Use `tea repos create`, `tea pulls create --base main --head <branch>`, `tea pulls list`, `tea issues create`, etc. for any persistent code work — git repos that should outlive a single turn, code you want a peer or the operator to review, anything you'd otherwise jam into `/shared`. Falls back to plain `git`/`curl` if `tea` doesn't fit; the REST API is at `http://localhost:3000/api/v1/` with the same token (`Authorization: token $(cat /agents/{label}/state/forge-token)`).
|
||||||
|
|
||||||
The `hive-forge` CLI helper wraps common Forgejo API operations: `view`, `issue`, `issue-create`, `issue-edit`, `pr`, `pr-create`, `comment`, `comment-show`, `comment-edit`, `assign`, `close`, `labels`, `milestone`, `pr-reviews`, `branches`, `tree-sha`, `diff`, `subscription`, `attach-issue`, `attach-comment`. Default repo comes from `HIVE_FORGE_REPO`; pass `-r <repo>` (global flag, works before or after the verb) to target a different repo. Every verb takes `--help` for its full signature. To create a PR: `hive-forge pr-create --title "..." --head <branch> [--base main] [--body "..." | --body-file <path>] [--draft]` — prints the PR URL. To create an issue: `hive-forge issue-create --title "..." [--body "..." | --body-file <path>] [--assignee <user>]`. `--body-file -` means stdin, so a HEREDOC body works naturally: `hive-forge comment <num> --body-file - <<EOF ... EOF`. To attach a file: `hive-forge attach-issue <number> <file>` / `hive-forge attach-comment <comment-id> <file>` — both print the `browser_download_url`. Key ops: `hive-forge diff <pr>` prints the unified diff; `hive-forge subscription [--watch|--ignore|--unwatch]` manages repo watch state. Note: forge notifications are delivered via the internal message daemon.
|
The `hive-forge` CLI helper wraps common Forgejo API operations: `view`, `issue`, `issue-create`, `issue-edit`, `pr`, `pr-create`, `comment`, `comments`, `comment-show`, `comment-edit`, `assign`, `close`, `labels`, `milestone`, `pr-reviews`, `branches`, `tree-sha`, `diff`, `subscription`, `attach-issue`, `attach-comment`. Default repo comes from `HIVE_FORGE_REPO`; pass `-r <repo>` (global flag, works before or after the verb) to target a different repo. Every verb takes `--help` for its full signature. To create a PR: `hive-forge pr-create --title "..." --head <branch> [--base main] [--body "..." | --body-file <path>] [--draft]` — prints the PR URL. To create an issue: `hive-forge issue-create --title "..." [--body "..." | --body-file <path>] [--assignee <user>]`. `--body-file -` means stdin, so a HEREDOC body works naturally: `hive-forge comment <num> --body-file - <<EOF ... EOF`. To attach a file: `hive-forge attach-issue <number> <file>` / `hive-forge attach-comment <comment-id> <file>` — both print the `browser_download_url`. Key ops: `hive-forge diff <pr>` prints the unified diff; `hive-forge subscription [--watch|--ignore|--unwatch]` manages repo watch state. Note: forge notifications are delivered via the internal message daemon.
|
||||||
|
|
||||||
Keep messages short — a few sentences each. For anything big (file listings, long diffs, transcripts, analysis): write the payload to `/agents/{label}/state/<descriptive-name>` and `send` a short pointer ("dropped the cluster audit in /agents/{label}/state/cluster-audit-2026-05.md, headline: 3 nodes over 80% mem"). The manager + operator can read your state from the host as `/agents/{label}/state/`. Sub-agent peers can't read each other's state directly — go through the manager if a payload needs to reach another sub-agent.
|
Keep messages short — a few sentences each. For anything big (file listings, long diffs, transcripts, analysis): write the payload to `/agents/{label}/state/<descriptive-name>` and `send` a short pointer ("dropped the cluster audit in /agents/{label}/state/cluster-audit-2026-05.md, headline: 3 nodes over 80% mem"). The manager + operator can read your state from the host as `/agents/{label}/state/`. Sub-agent peers can't read each other's state directly — go through the manager if a payload needs to reach another sub-agent.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ Keep messages short — a few sentences each. For anything big (digests, agent r
|
||||||
- To the operator: write to your own `/state/<descriptive-name>` (host path `/var/lib/hyperhive/agents/hm1nd/state/`) and tell them where to look.
|
- To the operator: write to your own `/state/<descriptive-name>` (host path `/var/lib/hyperhive/agents/hm1nd/state/`) and tell them where to look.
|
||||||
- For shared artifacts (coordination, common reference data): write to `/shared/<descriptive-name>`. Only put things here you're willing to lose — other agents may delete them.
|
- For shared artifacts (coordination, common reference data): write to `/shared/<descriptive-name>`. Only put things here you're willing to lose — other agents may delete them.
|
||||||
|
|
||||||
**Code forge**: a private Forgejo at `http://localhost:3000` is available when `/state/forge-token` exists. You have your own user (`hm1nd`) and so does every sub-agent (one per name). The `tea` CLI is pre-configured at boot. Use it for code work that should survive a turn — a proposed refactor across sub-agents, scratch repos, PRs you want a sub-agent or the operator to review (`tea pulls create --base main --head <branch>`, `tea pulls list`, `tea issues create`). REST API at `http://localhost:3000/api/v1/` with `Authorization: token $(cat /state/forge-token)` for anything `tea` can't express. The `hive-forge` CLI helper wraps common operations: `view`, `issue`, `issue-create`, `issue-edit`, `pr`, `pr-create`, `comment`, `comment-show`, `comment-edit`, `assign`, `close`, `labels`, `milestone`, `pr-reviews`, `branches`, `tree-sha`, `diff`, `subscription`, `attach-issue`, `attach-comment`. Default repo from `HIVE_FORGE_REPO`; `-r <repo>` (global flag, works before or after the verb) targets a different repo. Each verb takes `--help`. Use `hive-forge pr-create --title "..." --head <branch> [--base main] [--body "..." | --body-file <path>] [--draft]` to open a PR; `hive-forge issue-create --title "..." [--body "..." | --body-file <path>] [--assignee <user>]` to file an issue. `--body-file -` reads stdin, so a HEREDOC body works: `hive-forge comment <n> --body-file - <<EOF ... EOF`. `diff <pr>` prints the unified diff; `subscription [--watch|--ignore|--unwatch]` manages watch state. Forge notifications arrive via the internal message daemon.
|
**Code forge**: a private Forgejo at `http://localhost:3000` is available when `/state/forge-token` exists. You have your own user (`hm1nd`) and so does every sub-agent (one per name). The `tea` CLI is pre-configured at boot. Use it for code work that should survive a turn — a proposed refactor across sub-agents, scratch repos, PRs you want a sub-agent or the operator to review (`tea pulls create --base main --head <branch>`, `tea pulls list`, `tea issues create`). REST API at `http://localhost:3000/api/v1/` with `Authorization: token $(cat /state/forge-token)` for anything `tea` can't express. The `hive-forge` CLI helper wraps common operations: `view`, `issue`, `issue-create`, `issue-edit`, `pr`, `pr-create`, `comment`, `comments`, `comment-show`, `comment-edit`, `assign`, `close`, `labels`, `milestone`, `pr-reviews`, `branches`, `tree-sha`, `diff`, `subscription`, `attach-issue`, `attach-comment`. Default repo from `HIVE_FORGE_REPO`; `-r <repo>` (global flag, works before or after the verb) targets a different repo. Each verb takes `--help`. Use `hive-forge pr-create --title "..." --head <branch> [--base main] [--body "..." | --body-file <path>] [--draft]` to open a PR; `hive-forge issue-create --title "..." [--body "..." | --body-file <path>] [--assignee <user>]` to file an issue. `--body-file -` reads stdin, so a HEREDOC body works: `hive-forge comment <n> --body-file - <<EOF ... EOF`. `diff <pr>` prints the unified diff; `subscription [--watch|--ignore|--unwatch]` manages watch state. Forge notifications arrive via the internal message daemon.
|
||||||
|
|
||||||
A one-line headline + the file path beats a wall-of-text every time — it survives context compaction and the operator can read it in their own time.
|
A one-line headline + the file path beats a wall-of-text every time — it survives context compaction and the operator can read it in their own time.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,7 @@ async fn main() -> Result<()> {
|
||||||
&cli.socket,
|
&cli.socket,
|
||||||
Duration::from_millis(poll_ms),
|
Duration::from_millis(poll_ms),
|
||||||
login_state,
|
login_state,
|
||||||
|
claude_dir,
|
||||||
bus,
|
bus,
|
||||||
stats,
|
stats,
|
||||||
&files,
|
&files,
|
||||||
|
|
@ -116,6 +117,7 @@ async fn main() -> Result<()> {
|
||||||
&cli.socket,
|
&cli.socket,
|
||||||
Duration::from_millis(poll_ms),
|
Duration::from_millis(poll_ms),
|
||||||
login_state,
|
login_state,
|
||||||
|
claude_dir,
|
||||||
bus,
|
bus,
|
||||||
stats,
|
stats,
|
||||||
&files,
|
&files,
|
||||||
|
|
@ -153,7 +155,8 @@ async fn main() -> Result<()> {
|
||||||
async fn serve(
|
async fn serve(
|
||||||
socket: &Path,
|
socket: &Path,
|
||||||
interval: Duration,
|
interval: Duration,
|
||||||
_login_state: Arc<Mutex<LoginState>>,
|
login_state: Arc<Mutex<LoginState>>,
|
||||||
|
claude_dir: std::path::PathBuf,
|
||||||
bus: Bus,
|
bus: Bus,
|
||||||
stats: Option<TurnStats>,
|
stats: Option<TurnStats>,
|
||||||
files: &turn::TurnFiles,
|
files: &turn::TurnFiles,
|
||||||
|
|
@ -178,7 +181,23 @@ async fn serve(
|
||||||
match recv {
|
match recv {
|
||||||
Ok(AgentResponse::Messages { messages }) if !messages.is_empty() => {
|
Ok(AgentResponse::Messages { messages }) if !messages.is_empty() => {
|
||||||
let first = messages.into_iter().next().expect("checked non-empty");
|
let first = messages.into_iter().next().expect("checked non-empty");
|
||||||
handle_agent_turn(socket, &bus, stats.as_ref(), files, &turn_lock, label, first).await;
|
let auth_failed =
|
||||||
|
handle_agent_turn(socket, &bus, stats.as_ref(), files, &turn_lock, label, first)
|
||||||
|
.await;
|
||||||
|
if auth_failed {
|
||||||
|
// Park: flip LoginState + wait for the operator's
|
||||||
|
// re-auth to repopulate claude_dir. wait_for_login
|
||||||
|
// emits `online` on resume, which clears the
|
||||||
|
// needs_login sentinel.
|
||||||
|
*login_state.lock().unwrap() = LoginState::NeedsLogin;
|
||||||
|
turn::wait_for_login(
|
||||||
|
&claude_dir,
|
||||||
|
login_state.clone(),
|
||||||
|
&bus,
|
||||||
|
u64::try_from(interval.as_millis()).unwrap_or(2000),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(AgentResponse::Messages { .. }) => {
|
Ok(AgentResponse::Messages { .. }) => {
|
||||||
// Idle: empty list = nothing pending. Brief sleep
|
// Idle: empty list = nothing pending. Brief sleep
|
||||||
|
|
@ -208,7 +227,9 @@ async fn serve(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drive one turn for a received agent-inbox message.
|
/// Drive one turn for a received agent-inbox message. Returns `true`
|
||||||
|
/// when the turn ended with `AuthFailed` so the caller knows to park
|
||||||
|
/// in `wait_for_login`.
|
||||||
async fn handle_agent_turn(
|
async fn handle_agent_turn(
|
||||||
socket: &Path,
|
socket: &Path,
|
||||||
bus: &Bus,
|
bus: &Bus,
|
||||||
|
|
@ -217,7 +238,7 @@ async fn handle_agent_turn(
|
||||||
turn_lock: &TurnLock,
|
turn_lock: &TurnLock,
|
||||||
label: &str,
|
label: &str,
|
||||||
first: hive_sh4re::DeliveredMessage,
|
first: hive_sh4re::DeliveredMessage,
|
||||||
) {
|
) -> bool {
|
||||||
let from = first.from;
|
let from = first.from;
|
||||||
let body = first.body;
|
let body = first.body;
|
||||||
let redelivered = first.redelivered;
|
let redelivered = first.redelivered;
|
||||||
|
|
@ -251,6 +272,19 @@ async fn handle_agent_turn(
|
||||||
requeue_inflight(socket).await;
|
requeue_inflight(socket).await;
|
||||||
bus.emit_status("online");
|
bus.emit_status("online");
|
||||||
}
|
}
|
||||||
|
// 401: flip into needs_login + requeue the message that triggered
|
||||||
|
// the turn so it survives the re-auth. The serve loop's outer
|
||||||
|
// login-state watcher parks until the operator's `/login` flow
|
||||||
|
// completes; once it does, the requeued message replays the turn
|
||||||
|
// (closes #419).
|
||||||
|
if matches!(outcome, turn::TurnOutcome::AuthFailed) {
|
||||||
|
bus.emit_status("needs_login_idle");
|
||||||
|
bus.emit(LiveEvent::Note {
|
||||||
|
text: "API 401 — waiting for re-login via web UI".into(),
|
||||||
|
});
|
||||||
|
tracing::warn!("auth-failed; parking until re-login");
|
||||||
|
requeue_inflight(socket).await;
|
||||||
|
}
|
||||||
// Real crash: PromptTooLong is absorbed by compaction inside drive_turn.
|
// Real crash: PromptTooLong is absorbed by compaction inside drive_turn.
|
||||||
if let turn::TurnOutcome::Failed(e) = &outcome {
|
if let turn::TurnOutcome::Failed(e) = &outcome {
|
||||||
notify_manager_of_failure(socket, label, e).await;
|
notify_manager_of_failure(socket, label, e).await;
|
||||||
|
|
@ -280,6 +314,7 @@ async fn handle_agent_turn(
|
||||||
// `request_next_turn` MCP tool: agent wrote a sentinel requesting
|
// `request_next_turn` MCP tool: agent wrote a sentinel requesting
|
||||||
// an immediate self-continuation. Clear and inject synthetic wake.
|
// an immediate self-continuation. Clear and inject synthetic wake.
|
||||||
check_and_inject_continue(socket, label).await;
|
check_and_inject_continue(socket, label).await;
|
||||||
|
matches!(outcome, turn::TurnOutcome::AuthFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-turn user prompt: the role/tools/etc. is in the system prompt
|
// Per-turn user prompt: the role/tools/etc. is in the system prompt
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,8 @@ async fn main() -> Result<()> {
|
||||||
serve(
|
serve(
|
||||||
&cli.socket,
|
&cli.socket,
|
||||||
Duration::from_millis(poll_ms),
|
Duration::from_millis(poll_ms),
|
||||||
|
login_state,
|
||||||
|
claude_dir,
|
||||||
bus,
|
bus,
|
||||||
stats,
|
stats,
|
||||||
&files,
|
&files,
|
||||||
|
|
@ -96,10 +98,12 @@ async fn main() -> Result<()> {
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
LoginState::NeedsLogin => {
|
LoginState::NeedsLogin => {
|
||||||
turn::wait_for_login(&claude_dir, login_state, &bus, poll_ms).await;
|
turn::wait_for_login(&claude_dir, login_state.clone(), &bus, poll_ms).await;
|
||||||
serve(
|
serve(
|
||||||
&cli.socket,
|
&cli.socket,
|
||||||
Duration::from_millis(poll_ms),
|
Duration::from_millis(poll_ms),
|
||||||
|
login_state,
|
||||||
|
claude_dir,
|
||||||
bus,
|
bus,
|
||||||
stats,
|
stats,
|
||||||
&files,
|
&files,
|
||||||
|
|
@ -113,9 +117,12 @@ async fn main() -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn serve(
|
async fn serve(
|
||||||
socket: &Path,
|
socket: &Path,
|
||||||
interval: Duration,
|
interval: Duration,
|
||||||
|
login_state: Arc<Mutex<LoginState>>,
|
||||||
|
claude_dir: std::path::PathBuf,
|
||||||
bus: Bus,
|
bus: Bus,
|
||||||
stats: Option<TurnStats>,
|
stats: Option<TurnStats>,
|
||||||
files: &turn::TurnFiles,
|
files: &turn::TurnFiles,
|
||||||
|
|
@ -144,7 +151,19 @@ async fn serve(
|
||||||
match recv {
|
match recv {
|
||||||
Ok(ManagerResponse::Messages { messages }) if !messages.is_empty() => {
|
Ok(ManagerResponse::Messages { messages }) if !messages.is_empty() => {
|
||||||
let first = messages.into_iter().next().expect("checked non-empty");
|
let first = messages.into_iter().next().expect("checked non-empty");
|
||||||
handle_manager_turn(socket, &bus, stats.as_ref(), files, &turn_lock, first).await;
|
let auth_failed =
|
||||||
|
handle_manager_turn(socket, &bus, stats.as_ref(), files, &turn_lock, first)
|
||||||
|
.await;
|
||||||
|
if auth_failed {
|
||||||
|
*login_state.lock().unwrap() = LoginState::NeedsLogin;
|
||||||
|
turn::wait_for_login(
|
||||||
|
&claude_dir,
|
||||||
|
login_state.clone(),
|
||||||
|
&bus,
|
||||||
|
u64::try_from(interval.as_millis()).unwrap_or(2000),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(ManagerResponse::Messages { .. }) => {
|
Ok(ManagerResponse::Messages { .. }) => {
|
||||||
// Idle: empty list = nothing pending. Brief sleep
|
// Idle: empty list = nothing pending. Brief sleep
|
||||||
|
|
@ -176,6 +195,8 @@ async fn serve(
|
||||||
|
|
||||||
/// Drive one turn for a received manager-inbox message. Called from the
|
/// Drive one turn for a received manager-inbox message. Called from the
|
||||||
/// serve loop for the non-empty-messages arm to keep that loop readable.
|
/// serve loop for the non-empty-messages arm to keep that loop readable.
|
||||||
|
/// Returns `true` when the turn ended with `AuthFailed` so the caller
|
||||||
|
/// can park in `wait_for_login`.
|
||||||
async fn handle_manager_turn(
|
async fn handle_manager_turn(
|
||||||
socket: &Path,
|
socket: &Path,
|
||||||
bus: &Bus,
|
bus: &Bus,
|
||||||
|
|
@ -183,7 +204,7 @@ async fn handle_manager_turn(
|
||||||
files: &turn::TurnFiles,
|
files: &turn::TurnFiles,
|
||||||
turn_lock: &TurnLock,
|
turn_lock: &TurnLock,
|
||||||
first: hive_sh4re::DeliveredMessage,
|
first: hive_sh4re::DeliveredMessage,
|
||||||
) {
|
) -> bool {
|
||||||
let from = first.from;
|
let from = first.from;
|
||||||
let body = first.body;
|
let body = first.body;
|
||||||
let redelivered = first.redelivered;
|
let redelivered = first.redelivered;
|
||||||
|
|
@ -229,6 +250,14 @@ async fn handle_manager_turn(
|
||||||
requeue_inflight(socket).await;
|
requeue_inflight(socket).await;
|
||||||
bus.emit_status("online");
|
bus.emit_status("online");
|
||||||
}
|
}
|
||||||
|
if matches!(outcome, turn::TurnOutcome::AuthFailed) {
|
||||||
|
bus.emit_status("needs_login_idle");
|
||||||
|
bus.emit(LiveEvent::Note {
|
||||||
|
text: "API 401 — waiting for re-login via web UI".into(),
|
||||||
|
});
|
||||||
|
tracing::warn!("auth-failed; parking until re-login");
|
||||||
|
requeue_inflight(socket).await;
|
||||||
|
}
|
||||||
if let Some(stats) = stats {
|
if let Some(stats) = stats {
|
||||||
let ended_at = serve_common::now_unix();
|
let ended_at = serve_common::now_unix();
|
||||||
let duration_ms =
|
let duration_ms =
|
||||||
|
|
@ -251,6 +280,7 @@ async fn handle_manager_turn(
|
||||||
if pending > 0 {
|
if pending > 0 {
|
||||||
tracing::info!(%pending, "pending messages after turn; fetching next");
|
tracing::info!(%pending, "pending messages after turn; fetching next");
|
||||||
}
|
}
|
||||||
|
matches!(outcome, turn::TurnOutcome::AuthFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best-effort: tell the broker every message popped during the turn
|
/// Best-effort: tell the broker every message popped during the turn
|
||||||
|
|
|
||||||
|
|
@ -684,19 +684,33 @@ impl Bus {
|
||||||
/// `Arc<Mutex<LoginState>>` should also call this so the web UI
|
/// `Arc<Mutex<LoginState>>` should also call this so the web UI
|
||||||
/// drops its periodic /api/state poll while a turn loop is running.
|
/// drops its periodic /api/state poll while a turn loop is running.
|
||||||
///
|
///
|
||||||
/// `"rate_limited"` sets the rate-limited flag and writes a sentinel
|
/// Sentinel files survive harness restart so the host-side dashboard
|
||||||
/// file at `{state_dir}/hyperhive-rate-limited` so the host-side
|
/// can render the status without a live socket call:
|
||||||
/// dashboard can show the status without a live socket call.
|
/// - `"rate_limited"` writes `{state_dir}/hyperhive-rate-limited`
|
||||||
/// Any other status clears the flag and removes the sentinel.
|
/// (cleared by any other status).
|
||||||
|
/// - `"needs_login_idle"` writes `{state_dir}/hyperhive-needs-login`
|
||||||
|
/// so a 401-triggered re-auth flag persists across harness restart
|
||||||
|
/// (#419). The web UI's `/login` POST handler clears it via
|
||||||
|
/// `clear_needs_login_sentinel` once the operator re-auths.
|
||||||
|
/// - `"online"` clears both sentinels — the agent is healthy again.
|
||||||
pub fn emit_status(&self, status: impl Into<String>) {
|
pub fn emit_status(&self, status: impl Into<String>) {
|
||||||
let status = status.into();
|
let status = status.into();
|
||||||
let sentinel = crate::paths::state_dir().join("hyperhive-rate-limited");
|
let rate_limited_path = crate::paths::state_dir().join("hyperhive-rate-limited");
|
||||||
|
let needs_login_path = crate::paths::state_dir().join("hyperhive-needs-login");
|
||||||
if status == "rate_limited" {
|
if status == "rate_limited" {
|
||||||
self.rate_limited.store(true, Ordering::Relaxed);
|
self.rate_limited.store(true, Ordering::Relaxed);
|
||||||
let _ = std::fs::write(&sentinel, b"");
|
let _ = std::fs::write(&rate_limited_path, b"");
|
||||||
} else {
|
} else {
|
||||||
self.rate_limited.store(false, Ordering::Relaxed);
|
self.rate_limited.store(false, Ordering::Relaxed);
|
||||||
let _ = std::fs::remove_file(&sentinel);
|
let _ = std::fs::remove_file(&rate_limited_path);
|
||||||
|
}
|
||||||
|
if status == "needs_login_idle" {
|
||||||
|
let _ = std::fs::write(&needs_login_path, b"");
|
||||||
|
} else if status == "online" {
|
||||||
|
// Re-auth completed (or manual flip back to online) — drop
|
||||||
|
// the sentinel. `needs_login_in_progress` is a transient
|
||||||
|
// mid-flow status and shouldn't clear yet.
|
||||||
|
let _ = std::fs::remove_file(&needs_login_path);
|
||||||
}
|
}
|
||||||
self.emit(LiveEvent::StatusChanged { status });
|
self.emit(LiveEvent::StatusChanged { status });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ pub fn build_row(
|
||||||
TurnOutcome::Compacted => ("compacted", None),
|
TurnOutcome::Compacted => ("compacted", None),
|
||||||
TurnOutcome::PromptTooLong => ("prompt_too_long", None),
|
TurnOutcome::PromptTooLong => ("prompt_too_long", None),
|
||||||
TurnOutcome::RateLimited => ("rate_limited", None),
|
TurnOutcome::RateLimited => ("rate_limited", None),
|
||||||
|
TurnOutcome::AuthFailed => ("auth_failed", None),
|
||||||
TurnOutcome::Failed(e) => ("failed", Some(format!("{e:#}"))),
|
TurnOutcome::Failed(e) => ("failed", Some(format!("{e:#}"))),
|
||||||
};
|
};
|
||||||
TurnStatRow {
|
TurnStatRow {
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,21 @@ const RATE_LIMIT_MARKERS: &[&str] = &[
|
||||||
"Request rate limit exceeded",
|
"Request rate limit exceeded",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Substrings that indicate the Anthropic API rejected the request as
|
||||||
|
/// unauthenticated — the OAuth session in `/root/.claude/` has expired
|
||||||
|
/// or been revoked. Surfaced as `TurnOutcome::AuthFailed`, which the
|
||||||
|
/// harness uses to flip the container into `needs_login_idle` so the
|
||||||
|
/// dashboard's re-auth flow takes over (closes #419). Matched against
|
||||||
|
/// both stdout JSON `error` events and stderr; the markers come from
|
||||||
|
/// claude-code's `api_retry` events (`{"error":"authentication_failed",
|
||||||
|
/// "error_status":401,...}`) and the human-readable
|
||||||
|
/// "Failed to authenticate. API Error: 401" line claude prints on giveup.
|
||||||
|
const AUTH_FAIL_MARKERS: &[&str] = &[
|
||||||
|
"\"error\":\"authentication_failed\"",
|
||||||
|
"\"error_status\":401",
|
||||||
|
"Failed to authenticate. API Error: 401",
|
||||||
|
];
|
||||||
|
|
||||||
/// How long to sleep after detecting a rate-limit before re-entering the
|
/// How long to sleep after detecting a rate-limit before re-entering the
|
||||||
/// serve loop. Overridable via `HIVE_RATE_LIMIT_SLEEP_SECS`. Default is
|
/// serve loop. Overridable via `HIVE_RATE_LIMIT_SLEEP_SECS`. Default is
|
||||||
/// 5 minutes — enough for most short-lived throttles; the operator can
|
/// 5 minutes — enough for most short-lived throttles; the operator can
|
||||||
|
|
@ -196,6 +211,11 @@ pub enum TurnOutcome {
|
||||||
/// usage cap, or exhausted credit balance. The serve loop should park for
|
/// usage cap, or exhausted credit balance. The serve loop should park for
|
||||||
/// `rate_limit_sleep_secs()` and retry — NOT bubble up as a crash.
|
/// `rate_limit_sleep_secs()` and retry — NOT bubble up as a crash.
|
||||||
RateLimited,
|
RateLimited,
|
||||||
|
/// The Anthropic API rejected the request with 401 (OAuth session
|
||||||
|
/// expired or revoked). The serve loop should flip the container
|
||||||
|
/// into `needs_login_idle` and stop driving turns until the
|
||||||
|
/// operator re-auths via the per-agent web UI (closes #419).
|
||||||
|
AuthFailed,
|
||||||
Failed(anyhow::Error),
|
Failed(anyhow::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -343,6 +363,9 @@ async fn maybe_checkpoint_and_compact(files: &TurnFiles, bus: &Bus) -> bool {
|
||||||
TurnOutcome::RateLimited => bus.emit(LiveEvent::Note {
|
TurnOutcome::RateLimited => bus.emit(LiveEvent::Note {
|
||||||
text: "checkpoint turn was rate-limited — compacting anyway".into(),
|
text: "checkpoint turn was rate-limited — compacting anyway".into(),
|
||||||
}),
|
}),
|
||||||
|
TurnOutcome::AuthFailed => bus.emit(LiveEvent::Note {
|
||||||
|
text: "checkpoint turn hit 401 — skipping compaction, parking for re-login".into(),
|
||||||
|
}),
|
||||||
TurnOutcome::Failed(e) => bus.emit(LiveEvent::Note {
|
TurnOutcome::Failed(e) => bus.emit(LiveEvent::Note {
|
||||||
text: format!("checkpoint turn failed ({e:#}) — compacting anyway"),
|
text: format!("checkpoint turn failed ({e:#}) — compacting anyway"),
|
||||||
}),
|
}),
|
||||||
|
|
@ -413,6 +436,13 @@ pub fn emit_turn_end(bus: &Bus, outcome: &TurnOutcome) {
|
||||||
});
|
});
|
||||||
tracing::warn!("turn rate-limited");
|
tracing::warn!("turn rate-limited");
|
||||||
}
|
}
|
||||||
|
TurnOutcome::AuthFailed => {
|
||||||
|
bus.emit(LiveEvent::TurnEnd {
|
||||||
|
ok: false,
|
||||||
|
note: Some("authentication failed (401) — waiting for re-login".into()),
|
||||||
|
});
|
||||||
|
tracing::warn!("turn auth-failed (401)");
|
||||||
|
}
|
||||||
TurnOutcome::Failed(e) => {
|
TurnOutcome::Failed(e) => {
|
||||||
let note = format!("{e:#}");
|
let note = format!("{e:#}");
|
||||||
bus.emit(LiveEvent::TurnEnd {
|
bus.emit(LiveEvent::TurnEnd {
|
||||||
|
|
@ -461,8 +491,9 @@ pub async fn wait_for_login(
|
||||||
/// doesn't stall mid-turn — hyperhive owns compaction.
|
/// doesn't stall mid-turn — hyperhive owns compaction.
|
||||||
pub async fn run_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutcome {
|
pub async fn run_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutcome {
|
||||||
match run_claude(prompt, files, bus).await {
|
match run_claude(prompt, files, bus).await {
|
||||||
Ok((too_long, _)) if too_long => TurnOutcome::PromptTooLong,
|
Ok((true, _, _)) => TurnOutcome::PromptTooLong,
|
||||||
Ok((_, rate_limited)) if rate_limited => TurnOutcome::RateLimited,
|
Ok((_, true, _)) => TurnOutcome::RateLimited,
|
||||||
|
Ok((_, _, true)) => TurnOutcome::AuthFailed,
|
||||||
Ok(_) => TurnOutcome::Ok,
|
Ok(_) => TurnOutcome::Ok,
|
||||||
Err(e) => TurnOutcome::Failed(e),
|
Err(e) => TurnOutcome::Failed(e),
|
||||||
}
|
}
|
||||||
|
|
@ -483,7 +514,7 @@ pub async fn compact_session(files: &TurnFiles, bus: &Bus) -> Result<()> {
|
||||||
bus.emit(LiveEvent::Note {
|
bus.emit(LiveEvent::Note {
|
||||||
text: "context overflow — running /compact on the persistent session".into(),
|
text: "context overflow — running /compact on the persistent session".into(),
|
||||||
});
|
});
|
||||||
let (_, _) = run_claude("/compact", files, bus).await?;
|
let (_, _, _) = run_claude("/compact", files, bus).await?;
|
||||||
bus.emit(LiveEvent::Note {
|
bus.emit(LiveEvent::Note {
|
||||||
text: "/compact done".into(),
|
text: "/compact done".into(),
|
||||||
});
|
});
|
||||||
|
|
@ -491,7 +522,7 @@ pub async fn compact_session(files: &TurnFiles, bus: &Bus) -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<(bool, bool)> {
|
async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<(bool, bool, bool)> {
|
||||||
// Keep the last STDERR_TAIL_LINES of stderr so a non-zero exit can
|
// Keep the last STDERR_TAIL_LINES of stderr so a non-zero exit can
|
||||||
// include real context in the bail message (and downstream in the
|
// include real context in the bail message (and downstream in the
|
||||||
// failure notification to the manager) instead of just "exit 1".
|
// failure notification to the manager) instead of just "exit 1".
|
||||||
|
|
@ -547,10 +578,13 @@ async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<(bool,
|
||||||
|
|
||||||
let prompt_too_long = Arc::new(AtomicBool::new(false));
|
let prompt_too_long = Arc::new(AtomicBool::new(false));
|
||||||
let rate_limited = Arc::new(AtomicBool::new(false));
|
let rate_limited = Arc::new(AtomicBool::new(false));
|
||||||
|
let auth_failed = Arc::new(AtomicBool::new(false));
|
||||||
let flag_out = prompt_too_long.clone();
|
let flag_out = prompt_too_long.clone();
|
||||||
let flag_err = prompt_too_long.clone();
|
let flag_err = prompt_too_long.clone();
|
||||||
let rate_out = rate_limited.clone();
|
let rate_out = rate_limited.clone();
|
||||||
let rate_err = rate_limited.clone();
|
let rate_err = rate_limited.clone();
|
||||||
|
let auth_out = auth_failed.clone();
|
||||||
|
let auth_err = auth_failed.clone();
|
||||||
let bus_out = bus.clone();
|
let bus_out = bus.clone();
|
||||||
let bus_err = bus.clone();
|
let bus_err = bus.clone();
|
||||||
let pump_stdout = tokio::spawn(async move {
|
let pump_stdout = tokio::spawn(async move {
|
||||||
|
|
@ -566,6 +600,13 @@ async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<(bool,
|
||||||
if line.contains(PROMPT_TOO_LONG_MARKER) {
|
if line.contains(PROMPT_TOO_LONG_MARKER) {
|
||||||
flag_out.store(true, Ordering::Relaxed);
|
flag_out.store(true, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
// Auth-fail check happens on the raw line first so we
|
||||||
|
// catch both the `api_retry` JSON events (which can land
|
||||||
|
// before they're fully parseable) and any stderr-shaped
|
||||||
|
// text that snuck onto stdout.
|
||||||
|
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) {
|
match serde_json::from_str::<serde_json::Value>(&line) {
|
||||||
Ok(v) => {
|
Ok(v) => {
|
||||||
// Rate-limit detection: only fire on JSON `error` events,
|
// Rate-limit detection: only fire on JSON `error` events,
|
||||||
|
|
@ -628,6 +669,9 @@ async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<(bool,
|
||||||
if RATE_LIMIT_MARKERS.iter().any(|m| line.contains(m)) {
|
if RATE_LIMIT_MARKERS.iter().any(|m| line.contains(m)) {
|
||||||
rate_err.store(true, Ordering::Relaxed);
|
rate_err.store(true, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
if AUTH_FAIL_MARKERS.iter().any(|m| line.contains(m)) {
|
||||||
|
auth_err.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
// Mirror to journald so post-mortems work without the web UI
|
// Mirror to journald so post-mortems work without the web UI
|
||||||
// or the events sqlite. The bus event is what the dashboard
|
// or the events sqlite. The bus event is what the dashboard
|
||||||
// renders; the tracing line is what `journalctl -M <c> -b`
|
// renders; the tracing line is what `journalctl -M <c> -b`
|
||||||
|
|
@ -649,7 +693,8 @@ async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<(bool,
|
||||||
let _ = pump_stderr.await;
|
let _ = pump_stderr.await;
|
||||||
let too_long = prompt_too_long.load(Ordering::Relaxed);
|
let too_long = prompt_too_long.load(Ordering::Relaxed);
|
||||||
let is_rate_limited = rate_limited.load(Ordering::Relaxed);
|
let is_rate_limited = rate_limited.load(Ordering::Relaxed);
|
||||||
if !status.success() && !too_long && !is_rate_limited {
|
let is_auth_failed = auth_failed.load(Ordering::Relaxed);
|
||||||
|
if !status.success() && !too_long && !is_rate_limited && !is_auth_failed {
|
||||||
let tail = stderr_tail.lock().unwrap();
|
let tail = stderr_tail.lock().unwrap();
|
||||||
if tail.is_empty() {
|
if tail.is_empty() {
|
||||||
bail!("claude exited {status} (no stderr)");
|
bail!("claude exited {status} (no stderr)");
|
||||||
|
|
@ -657,5 +702,5 @@ async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<(bool,
|
||||||
let tail_str = tail.iter().cloned().collect::<Vec<_>>().join("\n");
|
let tail_str = tail.iter().cloned().collect::<Vec<_>>().join("\n");
|
||||||
bail!("claude exited {status}\nstderr tail:\n{tail_str}");
|
bail!("claude exited {status}\nstderr tail:\n{tail_str}");
|
||||||
}
|
}
|
||||||
Ok((too_long, is_rate_limited))
|
Ok((too_long, is_rate_limited, is_auth_failed))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -117,8 +117,14 @@ pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
|
||||||
};
|
};
|
||||||
let deployed_full = locked.get(&format!("agent-{logical}")).map(std::string::String::as_str);
|
let deployed_full = locked.get(&format!("agent-{logical}")).map(std::string::String::as_str);
|
||||||
let needs_update = crate::auto_update::agent_config_pending(&logical, deployed_full);
|
let needs_update = crate::auto_update::agent_config_pending(&logical, deployed_full);
|
||||||
let needs_login =
|
// needs_login fires when EITHER the claude session dir is
|
||||||
!is_manager && !claude_has_session(&Coordinator::agent_claude_dir(&logical));
|
// missing (boot-time / fresh container) OR the harness wrote
|
||||||
|
// the auth-failed sentinel because a turn hit 401 (#419). The
|
||||||
|
// manager has its own session lifecycle and never participates
|
||||||
|
// in needs_login.
|
||||||
|
let needs_login = !is_manager
|
||||||
|
&& (!claude_has_session(&Coordinator::agent_claude_dir(&logical))
|
||||||
|
|| auth_failed_sentinel(&logical));
|
||||||
let deployed_sha = deployed_full.map(|s| s[..s.len().min(12)].to_owned());
|
let deployed_sha = deployed_full.map(|s| s[..s.len().min(12)].to_owned());
|
||||||
// Recipient name the broker uses for this agent — sub-agents
|
// Recipient name the broker uses for this agent — sub-agents
|
||||||
// are addressed by logical name, the manager by the
|
// are addressed by logical name, the manager by the
|
||||||
|
|
@ -199,6 +205,17 @@ fn is_rate_limited(name: &str) -> bool {
|
||||||
.exists()
|
.exists()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True when the harness wrote `{state_dir}/hyperhive-needs-login`
|
||||||
|
/// after a 401 mid-turn. Lets the dashboard surface `needs_login` for
|
||||||
|
/// agents whose `/root/.claude/` dir still exists (so
|
||||||
|
/// `claude_has_session` returns true) but whose OAuth credentials
|
||||||
|
/// inside it have actually expired (#419).
|
||||||
|
fn auth_failed_sentinel(name: &str) -> bool {
|
||||||
|
Coordinator::agent_notes_dir(name)
|
||||||
|
.join("hyperhive-needs-login")
|
||||||
|
.exists()
|
||||||
|
}
|
||||||
|
|
||||||
/// Read the agent's free-text status and the Unix timestamp when it was last set
|
/// Read the agent's free-text status and the Unix timestamp when it was last set
|
||||||
/// (derived from the file's mtime). Returns `(None, None)` when the file is absent
|
/// (derived from the file's mtime). Returns `(None, None)` when the file is absent
|
||||||
/// or empty. `pub` so `agent_server` and `manager_server` can populate `AgentMeta`.
|
/// or empty. `pub` so `agent_server` and `manager_server` can populate `AgentMeta`.
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ enum Verb {
|
||||||
PrCreate(verbs::pr_create::Args),
|
PrCreate(verbs::pr_create::Args),
|
||||||
/// Post a comment on an issue or PR.
|
/// Post a comment on an issue or PR.
|
||||||
Comment(verbs::comment::Args),
|
Comment(verbs::comment::Args),
|
||||||
|
/// List all comments on an issue or PR.
|
||||||
|
Comments(verbs::comments::Args),
|
||||||
/// Print the body (or full JSON) of a single comment by id.
|
/// Print the body (or full JSON) of a single comment by id.
|
||||||
CommentShow(verbs::comment_show::Args),
|
CommentShow(verbs::comment_show::Args),
|
||||||
/// Edit an existing comment by id.
|
/// Edit an existing comment by id.
|
||||||
|
|
@ -94,6 +96,7 @@ fn main() -> Result<()> {
|
||||||
Verb::Pr(a) => verbs::pr::run(&client, a),
|
Verb::Pr(a) => verbs::pr::run(&client, a),
|
||||||
Verb::PrCreate(a) => verbs::pr_create::run(&client, a),
|
Verb::PrCreate(a) => verbs::pr_create::run(&client, a),
|
||||||
Verb::Comment(a) => verbs::comment::run(&client, a),
|
Verb::Comment(a) => verbs::comment::run(&client, a),
|
||||||
|
Verb::Comments(a) => verbs::comments::run(&client, a),
|
||||||
Verb::CommentShow(a) => verbs::comment_show::run(&client, a),
|
Verb::CommentShow(a) => verbs::comment_show::run(&client, a),
|
||||||
Verb::CommentEdit(a) => verbs::comment_edit::run(&client, a),
|
Verb::CommentEdit(a) => verbs::comment_edit::run(&client, a),
|
||||||
Verb::Assign(a) => verbs::assign::run(&client, a),
|
Verb::Assign(a) => verbs::assign::run(&client, a),
|
||||||
|
|
|
||||||
62
hive-forge/src/verbs/comments.rs
Normal file
62
hive-forge/src/verbs/comments.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
//! `comments <number> [--json] [--limit N]` — list all comments on
|
||||||
|
//! an issue or PR. Closes the curl-fallback gap (#418).
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Args as ClapArgs;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::client::Client;
|
||||||
|
use crate::verbs::print_json;
|
||||||
|
|
||||||
|
#[derive(ClapArgs)]
|
||||||
|
pub struct Args {
|
||||||
|
/// Issue or PR number.
|
||||||
|
number: u64,
|
||||||
|
/// Print as JSON array instead of human-readable markdown.
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
/// Page size (Forgejo caps at 50 by default).
|
||||||
|
#[arg(long, default_value_t = 50)]
|
||||||
|
limit: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
|
let repo = client.repo();
|
||||||
|
let v = client.get_json(&format!(
|
||||||
|
"/repos/{repo}/issues/{}/comments?limit={}",
|
||||||
|
args.number, args.limit
|
||||||
|
))?;
|
||||||
|
if args.json {
|
||||||
|
let trimmed: Vec<Value> = v
|
||||||
|
.as_array()
|
||||||
|
.map(|a| {
|
||||||
|
a.iter()
|
||||||
|
.map(|c| {
|
||||||
|
json!({
|
||||||
|
"id": c.get("id"),
|
||||||
|
"user": c.get("user").and_then(|u| u.get("login")),
|
||||||
|
"created_at": c.get("created_at"),
|
||||||
|
"updated_at": c.get("updated_at"),
|
||||||
|
"body": c.get("body"),
|
||||||
|
"url": c.get("html_url"),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
print_json(&Value::Array(trimmed))
|
||||||
|
} else {
|
||||||
|
for c in v.as_array().cloned().unwrap_or_default() {
|
||||||
|
let user = c
|
||||||
|
.get("user")
|
||||||
|
.and_then(|u| u.get("login"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("?");
|
||||||
|
let ts = c.get("created_at").and_then(Value::as_str).unwrap_or("?");
|
||||||
|
let body = c.get("body").and_then(Value::as_str).unwrap_or("");
|
||||||
|
println!("**{user} @ {ts}**: {body}");
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ pub mod close;
|
||||||
pub mod comment;
|
pub mod comment;
|
||||||
pub mod comment_edit;
|
pub mod comment_edit;
|
||||||
pub mod comment_show;
|
pub mod comment_show;
|
||||||
|
pub mod comments;
|
||||||
pub mod diff;
|
pub mod diff;
|
||||||
pub mod issue;
|
pub mod issue;
|
||||||
pub mod issue_create;
|
pub mod issue_create;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue