server-side TurnState in the harness, exposed via /api/state
new TurnState { Idle, Thinking, Compacting } on hive_ag3nt::events::Bus
with set_state + state_snapshot. the turn loops in hive-ag3nt and
hive-m1nd flip Thinking before drive_turn and Idle after; the
web_ui's /api/compact handler flips Compacting around compact_session.
per-agent /api/state grows turn_state + turn_state_since (unix
seconds). frontend prefers the server-reported state over the
client-derived one — setStateAbs takes the absolute since-time so
the 'last turn' chip reads the actual server-side duration instead
of the client's perceived gap between SSE events. SSE turn_start /
turn_end still drive state instantly between renders; /api/state
re-anchors on each turn_end refresh.
new compacting state gets its own purple badge with pulse
animation (mirrors thinking's amber). napping will slot in the
same way once the nap tool lands.
This commit is contained in:
parent
0385d96bf3
commit
637085644d
7 changed files with 94 additions and 32 deletions
17
TODO.md
17
TODO.md
|
|
@ -27,19 +27,10 @@ Pick anything from here when relevant. Cross-cutting design notes live in
|
||||||
|
|
||||||
## UI / UX
|
## UI / UX
|
||||||
|
|
||||||
- **State badge: compacting + napping states.** Idle/thinking already
|
- **State badge: napping state.** Idle / thinking / compacting
|
||||||
ship (driven from SSE turn_start/turn_end). Add `compacting 📦` and
|
already ship from server-side `TurnState`. Add `napping 😴`
|
||||||
`napping 😴` once the `/compact` trigger and `nap` tool exist —
|
once the `nap` tool exists — it just adds a new `TurnState`
|
||||||
both need a harness signal (an explicit `LiveEvent::StateChange`
|
variant the harness flips into for the duration of the nap.
|
||||||
variant or piggyback on Note).
|
|
||||||
- **Server-side state badge.** Today the badge is computed client-side
|
|
||||||
from `turn_start`/`turn_end` events. On page reload mid-turn the
|
|
||||||
history replay re-derives it, but with a `compacting` / `napping`
|
|
||||||
state coming and a non-trivial state machine it's better to track
|
|
||||||
authoritative state in the harness and expose it via
|
|
||||||
`GET /api/state` (`status: "thinking" | "idle" | "compacting" |
|
|
||||||
"napping"`). JS just renders. Drops the
|
|
||||||
derive-from-events-and-pray code path.
|
|
||||||
- **Terminal: `/model` slash command.** Operator-typeable model
|
- **Terminal: `/model` slash command.** Operator-typeable model
|
||||||
override from the terminal. Depends on the model-override work
|
override from the terminal. Depends on the model-override work
|
||||||
above; once an override mechanism exists, wire a `/model <name>`
|
above; once an override mechanism exists, wire a `/model <name>`
|
||||||
|
|
|
||||||
|
|
@ -228,6 +228,11 @@ pre.diff {
|
||||||
text-shadow: 0 0 6px rgba(250, 179, 135, 0.65);
|
text-shadow: 0 0 6px rgba(250, 179, 135, 0.65);
|
||||||
animation: badge-pulse 1.8s ease-in-out infinite;
|
animation: badge-pulse 1.8s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
.state-badge.state-compacting {
|
||||||
|
color: var(--purple); border-color: var(--purple);
|
||||||
|
text-shadow: 0 0 6px rgba(203, 166, 247, 0.65);
|
||||||
|
animation: badge-pulse 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
.state-badge.state-just-changed {
|
.state-badge.state-just-changed {
|
||||||
animation: state-flash 600ms ease-out;
|
animation: state-flash 600ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -291,10 +291,11 @@
|
||||||
// each second so the "· 12s" suffix stays current. State changes
|
// each second so the "· 12s" suffix stays current. State changes
|
||||||
// trigger a short flash animation via .state-just-changed.
|
// trigger a short flash animation via .state-just-changed.
|
||||||
const STATE_LABELS = {
|
const STATE_LABELS = {
|
||||||
loading: { glyph: '…', text: 'booting' },
|
loading: { glyph: '…', text: 'booting' },
|
||||||
offline: { glyph: '○', text: 'offline' },
|
offline: { glyph: '○', text: 'offline' },
|
||||||
idle: { glyph: '💤', text: 'idle' },
|
idle: { glyph: '💤', text: 'idle' },
|
||||||
thinking: { glyph: '🧠', text: 'thinking' },
|
thinking: { glyph: '🧠', text: 'thinking' },
|
||||||
|
compacting: { glyph: '📦', text: 'compacting' },
|
||||||
};
|
};
|
||||||
let stateName = 'loading';
|
let stateName = 'loading';
|
||||||
let stateSince = Date.now();
|
let stateSince = Date.now();
|
||||||
|
|
@ -318,19 +319,22 @@
|
||||||
if (cancelBtn) cancelBtn.hidden = stateName !== 'thinking';
|
if (cancelBtn) cancelBtn.hidden = stateName !== 'thinking';
|
||||||
}
|
}
|
||||||
function setState(next) {
|
function setState(next) {
|
||||||
if (next === stateName) return;
|
setStateAbs(next, Math.floor(Date.now() / 1000));
|
||||||
// Capture the just-ending state's duration when leaving 'thinking'
|
}
|
||||||
// so the operator can eyeball turn length without scrolling the
|
/// Set state with an authoritative since-unix from the server. Lets
|
||||||
// terminal back.
|
/// `last turn` track the actual server-side duration rather than
|
||||||
|
/// whatever the client perceived between SSE events.
|
||||||
|
function setStateAbs(next, sinceUnix) {
|
||||||
|
if (next === stateName && sinceUnix * 1000 === stateSince) return;
|
||||||
if (stateName === 'thinking' && next !== 'thinking') {
|
if (stateName === 'thinking' && next !== 'thinking') {
|
||||||
const elapsedMs = Date.now() - stateSince;
|
const elapsedMs = Date.now() - stateSince;
|
||||||
renderLastTurn(elapsedMs);
|
renderLastTurn(elapsedMs);
|
||||||
}
|
}
|
||||||
|
const flashing = next !== stateName;
|
||||||
stateName = next;
|
stateName = next;
|
||||||
stateSince = Date.now();
|
stateSince = sinceUnix * 1000;
|
||||||
const badge = $('state-badge');
|
const badge = $('state-badge');
|
||||||
if (badge) {
|
if (badge && flashing) {
|
||||||
// Re-add the flash class so the animation replays.
|
|
||||||
badge.classList.remove('state-just-changed');
|
badge.classList.remove('state-just-changed');
|
||||||
void badge.offsetWidth;
|
void badge.offsetWidth;
|
||||||
badge.classList.add('state-just-changed');
|
badge.classList.add('state-just-changed');
|
||||||
|
|
@ -411,11 +415,15 @@
|
||||||
if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; }
|
if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; }
|
||||||
renderTermInput(s.label, s.status === 'online');
|
renderTermInput(s.label, s.status === 'online');
|
||||||
renderInbox(s.inbox || []);
|
renderInbox(s.inbox || []);
|
||||||
// Drive the state badge from the harness status. Live SSE events
|
// Authoritative state comes from the harness via /api/state.
|
||||||
// override to 'thinking' / 'idle' as turns start/end; this only
|
// Login-not-yet → 'offline'; otherwise use the server-reported
|
||||||
// kicks in for the not-online (offline) case and the initial seed.
|
// turn_state (idle / thinking / compacting). stateSince in
|
||||||
if (s.status !== 'online') setState('offline');
|
// unix-seconds is converted to a client-side Date.now() anchor.
|
||||||
else if (stateName === 'loading' || stateName === 'offline') setState('idle');
|
if (s.status !== 'online') {
|
||||||
|
setState('offline');
|
||||||
|
} else if (s.turn_state) {
|
||||||
|
setStateAbs(s.turn_state, s.turn_state_since);
|
||||||
|
}
|
||||||
// Skip the re-render if nothing structurally changed. The most
|
// Skip the re-render if nothing structurally changed. The most
|
||||||
// common case is `online` polling itself — without this guard, the
|
// common case is `online` polling itself — without this guard, the
|
||||||
// operator's <input value> gets clobbered every cycle.
|
// operator's <input value> gets clobbered every cycle.
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use hive_ag3nt::events::{Bus, LiveEvent};
|
use hive_ag3nt::events::{Bus, LiveEvent, TurnState};
|
||||||
use hive_ag3nt::login::{self, LoginState};
|
use hive_ag3nt::login::{self, LoginState};
|
||||||
use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, turn, web_ui};
|
use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, turn, web_ui};
|
||||||
use hive_sh4re::{AgentRequest, AgentResponse};
|
use hive_sh4re::{AgentRequest, AgentResponse};
|
||||||
|
|
@ -126,6 +126,7 @@ async fn serve(
|
||||||
body: body.clone(),
|
body: body.clone(),
|
||||||
unread,
|
unread,
|
||||||
});
|
});
|
||||||
|
bus.set_state(TurnState::Thinking);
|
||||||
let prompt = format_wake_prompt(&from, &body, unread);
|
let prompt = format_wake_prompt(&from, &body, unread);
|
||||||
let outcome = turn::drive_turn(
|
let outcome = turn::drive_turn(
|
||||||
&prompt,
|
&prompt,
|
||||||
|
|
@ -137,6 +138,7 @@ async fn serve(
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
turn::emit_turn_end(&bus, &outcome);
|
turn::emit_turn_end(&bus, &outcome);
|
||||||
|
bus.set_state(TurnState::Idle);
|
||||||
}
|
}
|
||||||
Ok(AgentResponse::Empty) => {}
|
Ok(AgentResponse::Empty) => {}
|
||||||
Ok(AgentResponse::Ok | AgentResponse::Status { .. } | AgentResponse::Recent { .. }) => {
|
Ok(AgentResponse::Ok | AgentResponse::Status { .. } | AgentResponse::Recent { .. }) => {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use hive_ag3nt::events::{Bus, LiveEvent};
|
use hive_ag3nt::events::{Bus, LiveEvent, TurnState};
|
||||||
use hive_ag3nt::login::{self, LoginState};
|
use hive_ag3nt::login::{self, LoginState};
|
||||||
use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, turn, web_ui};
|
use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, turn, web_ui};
|
||||||
use hive_sh4re::{HelperEvent, ManagerRequest, ManagerResponse, SYSTEM_SENDER};
|
use hive_sh4re::{HelperEvent, ManagerRequest, ManagerResponse, SYSTEM_SENDER};
|
||||||
|
|
@ -124,6 +124,7 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> {
|
||||||
unread,
|
unread,
|
||||||
});
|
});
|
||||||
let prompt = format_wake_prompt(&from, &body, unread);
|
let prompt = format_wake_prompt(&from, &body, unread);
|
||||||
|
bus.set_state(TurnState::Thinking);
|
||||||
let outcome = turn::drive_turn(
|
let outcome = turn::drive_turn(
|
||||||
&prompt,
|
&prompt,
|
||||||
&mcp_config,
|
&mcp_config,
|
||||||
|
|
@ -134,6 +135,7 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> {
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
turn::emit_turn_end(&bus, &outcome);
|
turn::emit_turn_end(&bus, &outcome);
|
||||||
|
bus.set_state(TurnState::Idle);
|
||||||
}
|
}
|
||||||
Ok(ManagerResponse::Empty) => {}
|
Ok(ManagerResponse::Empty) => {}
|
||||||
Ok(
|
Ok(
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,14 @@ const HISTORY_CAPACITY: usize = 2000;
|
||||||
/// `HYPERHIVE_EVENTS_DB` env var (used in tests and one-shot tools).
|
/// `HYPERHIVE_EVENTS_DB` env var (used in tests and one-shot tools).
|
||||||
const DEFAULT_EVENTS_DB: &str = "/state/hyperhive-events.sqlite";
|
const DEFAULT_EVENTS_DB: &str = "/state/hyperhive-events.sqlite";
|
||||||
|
|
||||||
|
fn now_unix() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.ok()
|
||||||
|
.and_then(|d| i64::try_from(d.as_secs()).ok())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
const SCHEMA: &str = "
|
const SCHEMA: &str = "
|
||||||
CREATE TABLE IF NOT EXISTS events (
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|
@ -116,6 +124,22 @@ impl EventStore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Authoritative turn-loop state. The harness owns it; the web UI
|
||||||
|
/// reads via `/api/state` and renders. Lives alongside the bus
|
||||||
|
/// because everyone who has a `Bus` already has the right handle to
|
||||||
|
/// poke the state on transitions.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TurnState {
|
||||||
|
/// Inbox is empty / waiting on `Recv`.
|
||||||
|
Idle,
|
||||||
|
/// `claude --print` is running for a turn.
|
||||||
|
Thinking,
|
||||||
|
/// Operator-triggered `/compact` is running on the persistent
|
||||||
|
/// session.
|
||||||
|
Compacting,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Bus {
|
pub struct Bus {
|
||||||
tx: Arc<broadcast::Sender<LiveEvent>>,
|
tx: Arc<broadcast::Sender<LiveEvent>>,
|
||||||
|
|
@ -123,6 +147,8 @@ pub struct Bus {
|
||||||
/// at construction — we keep going so the harness doesn't die on a
|
/// at construction — we keep going so the harness doesn't die on a
|
||||||
/// missing `/state/` mount in dev / test scenarios.
|
/// missing `/state/` mount in dev / test scenarios.
|
||||||
store: Option<Arc<EventStore>>,
|
store: Option<Arc<EventStore>>,
|
||||||
|
/// Current turn-loop state + since-when (unix seconds).
|
||||||
|
state: Arc<Mutex<(TurnState, i64)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Bus {
|
impl Bus {
|
||||||
|
|
@ -144,9 +170,26 @@ impl Bus {
|
||||||
Self {
|
Self {
|
||||||
tx: Arc::new(tx),
|
tx: Arc::new(tx),
|
||||||
store,
|
store,
|
||||||
|
state: Arc::new(Mutex::new((TurnState::Idle, now_unix()))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update the harness's authoritative turn-loop state. Records
|
||||||
|
/// the transition time so `state_snapshot` can return a since-age.
|
||||||
|
pub fn set_state(&self, next: TurnState) {
|
||||||
|
let mut guard = self.state.lock().unwrap();
|
||||||
|
if guard.0 == next {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*guard = (next, now_unix());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current state + since-when (unix seconds). Snapshot copy, no lock held.
|
||||||
|
#[must_use]
|
||||||
|
pub fn state_snapshot(&self) -> (TurnState, i64) {
|
||||||
|
*self.state.lock().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn emit(&self, event: LiveEvent) {
|
pub fn emit(&self, event: LiveEvent) {
|
||||||
if let Some(store) = &self.store
|
if let Some(store) = &self.store
|
||||||
&& let Err(e) = store.append(&event)
|
&& let Err(e) = store.append(&event)
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,11 @@ struct StateSnapshot {
|
||||||
/// from the broker via the per-agent socket on each render.
|
/// from the broker via the per-agent socket on each render.
|
||||||
/// Empty on transport failure.
|
/// Empty on transport failure.
|
||||||
inbox: Vec<hive_sh4re::InboxRow>,
|
inbox: Vec<hive_sh4re::InboxRow>,
|
||||||
|
/// Authoritative turn-loop state from the harness and the unix
|
||||||
|
/// timestamp the state was entered. The JS computes the age
|
||||||
|
/// client-side off this rather than tracking it from SSE events.
|
||||||
|
turn_state: crate::events::TurnState,
|
||||||
|
turn_state_since: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -187,12 +192,15 @@ async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
|
||||||
.and_then(|s| s.parse::<u16>().ok())
|
.and_then(|s| s.parse::<u16>().ok())
|
||||||
.unwrap_or(7000);
|
.unwrap_or(7000);
|
||||||
let inbox = recent_inbox(&state.socket, state.flavor).await;
|
let inbox = recent_inbox(&state.socket, state.flavor).await;
|
||||||
|
let (turn_state, turn_state_since) = state.bus.state_snapshot();
|
||||||
axum::Json(StateSnapshot {
|
axum::Json(StateSnapshot {
|
||||||
label: state.label.clone(),
|
label: state.label.clone(),
|
||||||
dashboard_port,
|
dashboard_port,
|
||||||
status,
|
status,
|
||||||
session: session_view,
|
session: session_view,
|
||||||
inbox,
|
inbox,
|
||||||
|
turn_state,
|
||||||
|
turn_state_since,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -359,7 +367,10 @@ async fn post_compact(State(state): State<AppState>) -> Response {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Err(e) = crate::turn::compact_session(&settings, &bus).await {
|
bus.set_state(crate::events::TurnState::Compacting);
|
||||||
|
let r = crate::turn::compact_session(&settings, &bus).await;
|
||||||
|
bus.set_state(crate::events::TurnState::Idle);
|
||||||
|
if let Err(e) = r {
|
||||||
bus.emit(crate::events::LiveEvent::Note(format!(
|
bus.emit(crate::events::LiveEvent::Note(format!(
|
||||||
"/compact failed: {e:#}"
|
"/compact failed: {e:#}"
|
||||||
)));
|
)));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue