From bc87ff80d294d08b33ad2c084f637bbcb69c02ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 20:23:22 +0200 Subject: [PATCH 1/6] agent terminal: inline +/- diffs on Write and Edit tool calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Write and Edit tool_use rows used to render as the bare file path. now they're collapsed
blocks with the actual change inside — Write shows every content line prefixed '+', Edit shows old_string as '-' lines then new_string as '+' lines. summary carries the file path + counts ('→ Edit /foo · -3 +5'). lines colored via diff-add / diff-del / diff-ctx; click to expand the full body. renderFileWriteEdit returns null for any other tool so the existing flat-row path (fmtToolUse) is untouched. --- TODO.md | 9 ----- hive-ag3nt/assets/agent.css | 16 +++++++++ hive-ag3nt/assets/app.js | 65 ++++++++++++++++++++++++++++++++++++- 3 files changed, 80 insertions(+), 10 deletions(-) diff --git a/TODO.md b/TODO.md index dbfada2f..6410b53d 100644 --- a/TODO.md +++ b/TODO.md @@ -42,15 +42,6 @@ Pick anything from here when relevant. Cross-cutting design notes live in `GET /api/state` (`status: "thinking" | "idle" | "compacting" | "napping"`). JS just renders. Drops the derive-from-events-and-pray code path. -- **Terminal: inline diffs for Write/Edit.** Today a `Write` / - `Edit` tool-use row just shows the file path. Render the actual - change inline in the terminal: for `Edit`, a small `+`/`-` - per-line diff between `input.old_string` and `input.new_string`; - for `Write`, the first few lines of `input.content` (it's all - "+"). Keep collapsed by default (`
` like the existing - tool_result rollups), expand to full diff on click. Color via - the same `.diff-add` / `.diff-del` classes the dashboard - approval diff already uses. - **Terminal: `/model` slash command.** Operator-typeable model override from the terminal. Depends on the model-override work above; once an override mechanism exists, wire a `/model ` diff --git a/hive-ag3nt/assets/agent.css b/hive-ag3nt/assets/agent.css index 013fc82e..5e6b0c61 100644 --- a/hive-ag3nt/assets/agent.css +++ b/hive-ag3nt/assets/agent.css @@ -333,6 +333,22 @@ details.row > summary::before { } details.row[open] > summary::before { content: '▾ '; } details.row.tool-result-block > summary { color: var(--muted); } +/* Inline diff body for Write / Edit tool_use rows: same shape as + tool-body but each line is wrapped in a span with diff-add / + diff-del / diff-ctx so + / - lines are colored. */ +details.row > pre.diff-body { + margin: 0.3em 0 0.4em 1.2em; + padding: 0.4em 0.6em; + background: rgba(255, 255, 255, 0.02); + border-left: 2px solid var(--purple-dim); + white-space: pre-wrap; + word-break: break-word; + max-height: 22em; + overflow-y: auto; +} +details.row > pre.diff-body .diff-add { color: var(--green); } +details.row > pre.diff-body .diff-del { color: var(--red); } +details.row > pre.diff-body .diff-ctx { color: var(--fg); } details.row > pre.tool-body { margin: 0.3em 0 0.4em 1.2em; padding: 0.4em 0.6em; diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js index 0a40d59e..12a5404d 100644 --- a/hive-ag3nt/assets/app.js +++ b/hive-ag3nt/assets/app.js @@ -514,6 +514,63 @@ default: return name + ' ' + trim(JSON.stringify(input), 200); } } + // Build a tool_use row for Write/Edit as a collapsed
+ // showing the actual change. Returns null for any other tool so + // the caller falls back to the flat-row path. + // Write: every input.content line is "+". + // Edit: old_string lines as "-", new_string lines as "+". + // Not a true diff algorithm — claude's Edit blocks are already a + // contiguous old/new pair, so a literal -/+ rendering is honest. + function renderFileWriteEdit(c) { + const name = c.name || ''; + const input = c.input || {}; + if (name !== 'Write' && name !== 'Edit') return null; + const path = input.file_path || '?'; + let body; + let plus = 0; + let minus = 0; + if (name === 'Write') { + const content = String(input.content || ''); + const lines = content.split('\n'); + plus = lines.length; + body = lines.map(l => '+ ' + l).join('\n'); + } else { + const oldLines = String(input.old_string || '').split('\n'); + const newLines = String(input.new_string || '').split('\n'); + minus = oldLines.length; + plus = newLines.length; + body = oldLines.map(l => '- ' + l).join('\n') + + '\n' + + newLines.map(l => '+ ' + l).join('\n'); + } + const summary = '→ ' + name + ' ' + path + ' · ' + + (minus ? '-' + minus + ' ' : '') + '+' + plus; + return detailsDiff('tool-use', summary, body); + } + function detailsDiff(cls, summary, body) { + clearPlaceholder(); + const d = document.createElement('details'); + d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : ''); + const s = document.createElement('summary'); + s.textContent = summary; + d.appendChild(s); + const pre = document.createElement('pre'); + pre.className = 'tool-body diff-body'; + // Color each line by its leading +/-. + for (const line of body.split('\n')) { + const span = document.createElement('span'); + if (line.startsWith('+ ')) span.className = 'diff-add'; + else if (line.startsWith('- ')) span.className = 'diff-del'; + else span.className = 'diff-ctx'; + span.textContent = line + '\n'; + pre.appendChild(span); + } + d.appendChild(pre); + log.appendChild(d); + afterAppend(); + return d; + } + function renderToolResult(c) { const txt = Array.isArray(c.content) ? c.content.map(p => p.text || '').join('') @@ -547,7 +604,13 @@ const txt = (c.thinking || c.text || '').trim(); row('thinking', txt ? '· ' + txt : '· thinking …'); } - else if (c.type === 'tool_use') row('tool-use', '→ ' + fmtToolUse(c)); + else if (c.type === 'tool_use') { + // Write/Edit get a collapsed +/- diff body; everything + // else stays as the flat row produced by fmtToolUse. + if (!renderFileWriteEdit(c)) { + row('tool-use', '→ ' + fmtToolUse(c)); + } + } } return; } From ee5b85716d896c2f82afe2260ea09fa3fd7a32c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 20:25:11 +0200 Subject: [PATCH 2/6] =?UTF-8?q?ask=5Foperator:=20operator-side=20=E2=9C=97?= =?UTF-8?q?=20CANC3L=20on=20pending=20questions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new POST /cancel-question/{id} resolves a pending operator question with the sentinel answer '[cancelled]' and fires the usual HelperEvent::OperatorAnswered so the manager sees a terminal state and can fall back. uses the same OperatorQuestions::answer path — no special handling, the manager already has to deal with arbitrary answer strings. dashboard renders the cancel as a separate
below the main qform so the answer-merge submit handler on the main form doesn't inadvertently fire when the operator clicks cancel. confirm dialog spells out what the manager will see. ttl-based auto-cancel is still on the todo (would spawn a tokio task per submitted question). --- TODO.md | 13 +++++++------ hive-c0re/assets/app.js | 20 ++++++++++++++++++-- hive-c0re/assets/dashboard.css | 1 + hive-c0re/src/dashboard.rs | 28 ++++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/TODO.md b/TODO.md index 6410b53d..58013ad6 100644 --- a/TODO.md +++ b/TODO.md @@ -70,12 +70,13 @@ Pick anything from here when relevant. Cross-cutting design notes live in ## Manager → operator question channel -- **TTL / cancel on `ask_operator`.** Questions today block forever; the - manager turn stays alive until the operator answers. Add a per-question - `ttl_seconds` (or a dashboard "cancel" button that resolves the question - with a sentinel answer) so a long-idle question can time out and let the - manager fall back. Wire the timeout into `OperatorQuestions::wait_answered` - and surface remaining-time on the dashboard. +- **TTL on `ask_operator`.** Manual cancel via dashboard already + ships (✗ CANC3L button resolves the question with answer + `[cancelled]` and fires `OperatorAnswered` so the manager sees a + terminal state). Still missing: per-question `ttl_seconds` that + auto-cancels after a deadline. Spawn a tokio task per submitted + question that calls the same cancel path after the ttl expires + (cheap; rare). Surface remaining time on the dashboard. ## Spawn flow diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 889aedde..feb1926d 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -284,12 +284,28 @@ if (!merged) { ev.preventDefault(); alert('pick an option or type an answer'); } }, true); if (hasOptions) f.append(optionGroup); - f.append( - el('div', { class: 'q-free' }, freeText), + const buttons = el('div', { class: 'q-buttons' }); + buttons.append( el('button', { type: 'submit', class: 'btn btn-approve' }, isMulti ? '▸ ANSW3R · ' + (q.options.length) + ' opts' : '▸ ANSW3R'), ); + f.append( + el('div', { class: 'q-free' }, freeText), + buttons, + ); li.append(f); + // Separate form so the cancel button doesn't get the answer + // merge-on-submit handler attached to the main form. + const cancelForm = el('form', { + method: 'POST', action: '/cancel-question/' + q.id, + class: 'qform-cancel', 'data-async': '', + 'data-confirm': 'cancel this question? manager will see ' + + '"[cancelled]" as the answer.', + }); + cancelForm.append( + el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ CANC3L'), + ); + li.append(cancelForm); ul.append(li); } root.append(ul); diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index 1e0792ae..5fc4c4ef 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -332,6 +332,7 @@ summary:hover { color: var(--purple); } .qform .q-free input::placeholder { color: var(--muted); } .qform .q-free input:focus { outline: 1px solid var(--amber); } .qform button { align-self: flex-start; } +.qform-cancel { margin-top: 0.3em; } .inbox { background: var(--bg-elev); border: 1px solid var(--border); diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index eed42cd6..0188fd73 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -51,6 +51,7 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/rebuild/{name}", post(post_rebuild)) .route("/update-all", post(post_update_all)) .route("/answer-question/{id}", post(post_answer_question)) + .route("/cancel-question/{id}", post(post_cancel_question)) .route("/purge-tombstone/{name}", post(post_purge_tombstone)) .route("/request-spawn", post(post_request_spawn)) .route("/messages/stream", get(messages_stream)) @@ -417,6 +418,33 @@ async fn post_answer_question( } } +/// Resolve a pending operator question with a sentinel answer when +/// the operator decides not to / can't answer. The manager harness +/// receives an `OperatorAnswered` event with `answer = "[cancelled]"` +/// so it can fall back on whatever default it had. Same code path as +/// a real answer — just lets the operator close the loop instead of +/// letting the question dangle forever. +async fn post_cancel_question( + State(state): State, + AxumPath(id): AxumPath, +) -> Response { + const SENTINEL: &str = "[cancelled]"; + match state.coord.questions.answer(id, SENTINEL) { + Ok(question) => { + tracing::info!(%id, "operator cancelled question"); + state + .coord + .notify_manager(&hive_sh4re::HelperEvent::OperatorAnswered { + id, + question, + answer: SENTINEL.to_owned(), + }); + Redirect::to("/").into_response() + } + Err(e) => error_response(&format!("cancel-question {id} failed: {e:#}")), + } +} + async fn post_purge_tombstone( State(state): State, AxumPath(name): AxumPath, From bd7d2d486033b670d55d813ceda533ab33ebd23c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 20:27:09 +0200 Subject: [PATCH 3/6] agent page: dashboard back-link + last-turn timing chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit title bar grows a '↑ DASHB04RD' link next to the rebuild button — opens the host dashboard in a new tab so the operator can pivot between agents without losing the live tail. uses the dashboardPort already plumbed via /api/state. state row picks up a 'last turn 12.3s' chip that fills in when state transitions away from thinking. format: ms / s.s / m s. hidden until the first turn completes. --- TODO.md | 8 ++++++-- hive-ag3nt/assets/agent.css | 20 ++++++++++++++++++++ hive-ag3nt/assets/app.js | 30 ++++++++++++++++++++++++++++-- hive-ag3nt/assets/index.html | 1 + 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index 58013ad6..20bb3daf 100644 --- a/TODO.md +++ b/TODO.md @@ -27,8 +27,12 @@ Pick anything from here when relevant. Cross-cutting design notes live in ## UI / UX -- **Per-agent UI substance.** Show last N inbox messages, last turn timing, - link back to dashboard. +- **Per-agent inbox view.** Show the last N messages addressed to + this agent on its page (the per-agent equivalent of the + dashboard's operator inbox). Needs a new wire request from agent + → host (host has the broker; agent doesn't); reuse the broker's + `recent_for` query. Last-turn timing + dashboard back-link + already shipped. - **State badge: compacting + napping states.** Idle/thinking already ship (driven from SSE turn_start/turn_end). Add `compacting 📦` and `napping 😴` once the `/compact` trigger and `nap` tool exist — diff --git a/hive-ag3nt/assets/agent.css b/hive-ag3nt/assets/agent.css index 5e6b0c61..3624f19f 100644 --- a/hive-ag3nt/assets/agent.css +++ b/hive-ag3nt/assets/agent.css @@ -130,6 +130,26 @@ pre.diff { align-items: center; gap: 0.6em; } +.last-turn { + color: var(--muted); + font-size: 0.8em; + letter-spacing: 0.05em; +} +.btn-dashlink { + color: var(--cyan); + border: 1px solid var(--cyan); + padding: 0.15em 0.6em; + font-size: 0.55em; + font-family: inherit; + text-decoration: none; + letter-spacing: 0.1em; + margin-left: 0.6em; + vertical-align: middle; +} +.btn-dashlink:hover { + background: rgba(137, 220, 235, 0.1); + box-shadow: 0 0 10px -2px currentColor; +} .btn-cancel-turn { font-family: inherit; font-size: 0.8em; diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js index 12a5404d..f56bf7f2 100644 --- a/hive-ag3nt/assets/app.js +++ b/hive-ag3nt/assets/app.js @@ -65,16 +65,25 @@ `░▒▓█▓▒░ ${label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░`; const title = $('title'); title.textContent = `◆ ${label} ◆ `; + // ↑ DASHB04RD — back-link to the host dashboard. Opens in a new + // tab to keep the agent page anchored where the operator is. + const dashUrl = `${location.protocol}//${location.hostname}:${dashboardPort}/`; + title.append( + el('a', { + href: dashUrl, target: '_blank', rel: 'noopener', + class: 'btn-dashlink', title: 'host dashboard', + }, '↑ DASHB04RD'), + ' ', + ); const btn = el('a', { href: '#', class: 'btn-rebuild', id: 'rebuild-btn', }, '↻ R3BU1LD'); btn.addEventListener('click', (e) => { e.preventDefault(); if (!confirm(`rebuild ${label}? container will hot-reload.`)) return; - const url = `${location.protocol}//${location.hostname}:${dashboardPort}/rebuild/${label}`; const f = document.createElement('form'); f.method = 'POST'; - f.action = url; + f.action = `${dashUrl}rebuild/${label}`; document.body.appendChild(f); f.submit(); }); @@ -310,6 +319,13 @@ } function setState(next) { if (next === stateName) return; + // Capture the just-ending state's duration when leaving 'thinking' + // so the operator can eyeball turn length without scrolling the + // terminal back. + if (stateName === 'thinking' && next !== 'thinking') { + const elapsedMs = Date.now() - stateSince; + renderLastTurn(elapsedMs); + } stateName = next; stateSince = Date.now(); const badge = $('state-badge'); @@ -321,6 +337,16 @@ } renderStateBadge(); } + function renderLastTurn(ms) { + const el_ = $('last-turn'); + if (!el_) return; + let s = ''; + if (ms < 1000) s = ms + 'ms'; + else if (ms < 60_000) s = (ms / 1000).toFixed(1) + 's'; + else s = Math.floor(ms / 60_000) + 'm ' + Math.floor((ms / 1000) % 60) + 's'; + el_.textContent = '· last turn ' + s; + el_.hidden = false; + } function startStateTicker() { if (stateTickTimer) return; stateTickTimer = setInterval(renderStateBadge, 1000); diff --git a/hive-ag3nt/assets/index.html b/hive-ag3nt/assets/index.html index 397fd327..5d22c30e 100644 --- a/hive-ag3nt/assets/index.html +++ b/hive-ag3nt/assets/index.html @@ -15,6 +15,7 @@
… booting +
From 538e0446d7e5bb4ef43697df4b6bbb4f4d862cb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 20:32:19 +0200 Subject: [PATCH 4/6] agent page: inbox view of last 30 messages addressed to this agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new wire request AgentRequest::Recent { limit } / ManagerRequest::Recent (plus matching responses with Vec). InboxRow moved to hive-sh4re so it lives on both surfaces without an internal-to-wire conversion. host-side dispatch in agent_server / manager_server calls broker.recent_for(name, limit). per-agent web_ui /api/state grew an inbox: Vec populated via the same per-agent socket (best-effort; transport failure returns empty). frontend renders as a collapsible
section between the state row and the terminal — fmt timestamp / from / body in a tight grid, capped at 16em scrollable. only visible when there are rows. --- TODO.md | 6 ------ hive-ag3nt/assets/agent.css | 36 +++++++++++++++++++++++++++++++ hive-ag3nt/assets/app.js | 25 +++++++++++++++++++++ hive-ag3nt/assets/index.html | 5 +++++ hive-ag3nt/src/bin/hive-ag3nt.rs | 2 +- hive-ag3nt/src/bin/hive-m1nd.rs | 3 ++- hive-ag3nt/src/mcp.rs | 3 +++ hive-ag3nt/src/web_ui.rs | 37 ++++++++++++++++++++++++++++++++ hive-c0re/src/agent_server.rs | 6 ++++++ hive-c0re/src/broker.rs | 12 +---------- hive-c0re/src/dashboard.rs | 2 +- hive-c0re/src/manager_server.rs | 6 ++++++ hive-sh4re/src/lib.rs | 28 ++++++++++++++++++++++++ 13 files changed, 151 insertions(+), 20 deletions(-) diff --git a/TODO.md b/TODO.md index 20bb3daf..1896fede 100644 --- a/TODO.md +++ b/TODO.md @@ -27,12 +27,6 @@ Pick anything from here when relevant. Cross-cutting design notes live in ## UI / UX -- **Per-agent inbox view.** Show the last N messages addressed to - this agent on its page (the per-agent equivalent of the - dashboard's operator inbox). Needs a new wire request from agent - → host (host has the broker; agent doesn't); reuse the broker's - `recent_for` query. Last-turn timing + dashboard back-link - already shipped. - **State badge: compacting + napping states.** Idle/thinking already ship (driven from SSE turn_start/turn_end). Add `compacting 📦` and `napping 😴` once the `/compact` trigger and `nap` tool exist — diff --git a/hive-ag3nt/assets/agent.css b/hive-ag3nt/assets/agent.css index 3624f19f..13031836 100644 --- a/hive-ag3nt/assets/agent.css +++ b/hive-ag3nt/assets/agent.css @@ -130,6 +130,42 @@ pre.diff { align-items: center; gap: 0.6em; } +/* Per-agent inbox section — collapsible, dim, lives between the + state row and the terminal so the operator can peek at what + landed without scrolling through the live tail. */ +.agent-inbox { + margin: 0.4em 0; + font-size: 0.85em; + color: var(--muted); +} +.agent-inbox > summary { + cursor: pointer; + letter-spacing: 0.05em; + list-style: none; +} +.agent-inbox > summary::marker { content: ''; } +.agent-inbox[open] > summary > span::before { content: ''; } +.agent-inbox ul { + list-style: none; + padding: 0.4em 0.8em; + margin: 0.3em 0 0; + background: rgba(255, 255, 255, 0.02); + border-left: 2px solid var(--purple-dim); + max-height: 16em; + overflow-y: auto; +} +.agent-inbox li { + padding: 0.15em 0; + display: grid; + grid-template-columns: auto auto auto 1fr; + gap: 0.5em; + align-items: baseline; +} +.agent-inbox .inbox-ts { color: var(--muted); font-size: 0.9em; } +.agent-inbox .inbox-from { color: var(--amber); } +.agent-inbox .inbox-sep { color: var(--muted); } +.agent-inbox .inbox-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; } + .last-turn { color: var(--muted); font-size: 0.8em; diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js index f56bf7f2..2702e7cc 100644 --- a/hive-ag3nt/assets/app.js +++ b/hive-ag3nt/assets/app.js @@ -337,6 +337,30 @@ } renderStateBadge(); } + function renderInbox(rows) { + const root = $('inbox-section'); + const list = $('inbox-list'); + const summary = $('inbox-summary'); + if (!root || !list || !summary) return; + if (!rows.length) { + root.hidden = true; + return; + } + root.hidden = false; + summary.textContent = 'inbox · ' + rows.length; + list.innerHTML = ''; + const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(5, 19); + for (const m of rows) { + const li = el('li'); + li.append( + el('span', { class: 'inbox-ts' }, fmt(m.at)), ' ', + el('span', { class: 'inbox-from' }, m.from), ' ', + el('span', { class: 'inbox-sep' }, '→'), ' ', + el('span', { class: 'inbox-body' }, m.body), + ); + list.append(li); + } + } function renderLastTurn(ms) { const el_ = $('last-turn'); if (!el_) return; @@ -386,6 +410,7 @@ const s = await resp.json(); if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; } renderTermInput(s.label, s.status === 'online'); + renderInbox(s.inbox || []); // Drive the state badge from the harness status. Live SSE events // override to 'thinking' / 'idle' as turns start/end; this only // kicks in for the not-online (offline) case and the initial seed. diff --git a/hive-ag3nt/assets/index.html b/hive-ag3nt/assets/index.html index 5d22c30e..9906176c 100644 --- a/hive-ag3nt/assets/index.html +++ b/hive-ag3nt/assets/index.html @@ -19,6 +19,11 @@ + +
connecting…
diff --git a/hive-ag3nt/src/bin/hive-ag3nt.rs b/hive-ag3nt/src/bin/hive-ag3nt.rs index e1bff364..6f899e65 100644 --- a/hive-ag3nt/src/bin/hive-ag3nt.rs +++ b/hive-ag3nt/src/bin/hive-ag3nt.rs @@ -139,7 +139,7 @@ async fn serve( turn::emit_turn_end(&bus, &outcome); } Ok(AgentResponse::Empty) => {} - Ok(AgentResponse::Ok | AgentResponse::Status { .. }) => { + Ok(AgentResponse::Ok | AgentResponse::Status { .. } | AgentResponse::Recent { .. }) => { tracing::warn!("recv produced unexpected response kind"); } Ok(AgentResponse::Err { message }) => { diff --git a/hive-ag3nt/src/bin/hive-m1nd.rs b/hive-ag3nt/src/bin/hive-m1nd.rs index d3cbd8d7..06a61704 100644 --- a/hive-ag3nt/src/bin/hive-m1nd.rs +++ b/hive-ag3nt/src/bin/hive-m1nd.rs @@ -139,7 +139,8 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> { Ok( ManagerResponse::Ok | ManagerResponse::Status { .. } - | ManagerResponse::QuestionQueued { .. }, + | ManagerResponse::QuestionQueued { .. } + | ManagerResponse::Recent { .. }, ) => { tracing::warn!("recv produced unexpected response kind"); } diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index d48f4d8e..cf1d76b3 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -38,6 +38,7 @@ pub enum SocketReply { Empty, Status(u64), QuestionQueued(i64), + Recent(Vec), } impl From for SocketReply { @@ -48,6 +49,7 @@ impl From for SocketReply { hive_sh4re::AgentResponse::Message { from, body } => Self::Message { from, body }, hive_sh4re::AgentResponse::Empty => Self::Empty, hive_sh4re::AgentResponse::Status { unread } => Self::Status(unread), + hive_sh4re::AgentResponse::Recent { rows } => Self::Recent(rows), } } } @@ -61,6 +63,7 @@ impl From for SocketReply { hive_sh4re::ManagerResponse::Empty => Self::Empty, hive_sh4re::ManagerResponse::Status { unread } => Self::Status(unread), hive_sh4re::ManagerResponse::QuestionQueued { id } => Self::QuestionQueued(id), + hive_sh4re::ManagerResponse::Recent { rows } => Self::Recent(rows), } } } diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index e0ea25f7..92288577 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -124,6 +124,10 @@ struct StateSnapshot { status: &'static str, /// Present when `status == "needs_login_in_progress"`. session: Option, + /// Last N messages addressed to this agent, newest-first. Pulled + /// from the broker via the per-agent socket on each render. + /// Empty on transport failure. + inbox: Vec, } #[derive(Serialize)] @@ -157,14 +161,47 @@ async fn api_state(State(state): State) -> axum::Json { .ok() .and_then(|s| s.parse::().ok()) .unwrap_or(7000); + let inbox = recent_inbox(&state.socket, state.flavor).await; axum::Json(StateSnapshot { label: state.label.clone(), dashboard_port, status, session: session_view, + inbox, }) } +/// Best-effort: pull the last 30 messages addressed to us via the +/// per-agent / manager socket. Empty list on any transport / decode +/// failure — the inbox section is decorative, not authoritative. +async fn recent_inbox(socket: &std::path::Path, flavor: Flavor) -> Vec { + const LIMIT: u64 = 30; + match flavor { + Flavor::Agent => { + match client::request::<_, hive_sh4re::AgentResponse>( + socket, + &hive_sh4re::AgentRequest::Recent { limit: LIMIT }, + ) + .await + { + Ok(hive_sh4re::AgentResponse::Recent { rows }) => rows, + _ => Vec::new(), + } + } + Flavor::Manager => { + match client::request::<_, hive_sh4re::ManagerResponse>( + socket, + &hive_sh4re::ManagerRequest::Recent { limit: LIMIT }, + ) + .await + { + Ok(hive_sh4re::ManagerResponse::Recent { rows }) => rows, + _ => Vec::new(), + } + } + } +} + // --------------------------------------------------------------------------- // Action handlers // --------------------------------------------------------------------------- diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index 82329cae..1dacf469 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -121,5 +121,11 @@ async fn dispatch(req: &AgentRequest, agent: &str, broker: &Broker) -> AgentResp message: format!("{e:#}"), }, }, + AgentRequest::Recent { limit } => match broker.recent_for(agent, *limit) { + Ok(rows) => AgentResponse::Recent { rows }, + Err(e) => AgentResponse::Err { + message: format!("{e:#}"), + }, + }, } } diff --git a/hive-c0re/src/broker.rs b/hive-c0re/src/broker.rs index bd6b45f9..72486bad 100644 --- a/hive-c0re/src/broker.rs +++ b/hive-c0re/src/broker.rs @@ -6,7 +6,7 @@ use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; -use hive_sh4re::Message; +use hive_sh4re::{InboxRow, Message}; use rusqlite::{Connection, OptionalExtension, params}; use serde::Serialize; use tokio::sync::broadcast; @@ -28,16 +28,6 @@ CREATE INDEX IF NOT EXISTS idx_messages_undelivered /// may drop events past this; we send a `lagged` notice in their stream. const EVENT_CHANNEL: usize = 256; -/// One row in a `recent_for()` query — the broker's flat view of a -/// message addressed to a given recipient. -#[derive(Debug, Clone, Serialize)] -pub struct InboxRow { - pub id: i64, - pub from: String, - pub body: String, - pub at: i64, -} - #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "snake_case", tag = "kind")] pub enum MessageEvent { diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 0188fd73..eed8f9b4 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -102,7 +102,7 @@ struct StateSnapshot { /// Latest messages addressed to `operator` — surfaces agent replies /// asynchronously so the operator can see them without watching the /// live panel during a turn. - operator_inbox: Vec, + operator_inbox: Vec, /// Pending operator questions (currently only from the manager). /// `ask_operator` returns immediately with the id; on `/answer-question` /// we mark the row answered and fire `HelperEvent::OperatorAnswered` diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index 9e4ae18c..efefd64b 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -100,6 +100,12 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse message: format!("{e:#}"), }, }, + ManagerRequest::Recent { limit } => match coord.broker.recent_for(MANAGER_AGENT, *limit) { + Ok(rows) => ManagerResponse::Recent { rows }, + Err(e) => ManagerResponse::Err { + message: format!("{e:#}"), + }, + }, ManagerRequest::Recv => match coord .broker .recv_blocking(MANAGER_AGENT, MANAGER_RECV_LONG_POLL) diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index a1c34044..56eead0d 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -146,6 +146,19 @@ pub struct Message { pub body: String, } +/// One row of a broker inbox query — what the dashboard renders in +/// its operator-inbox section and what a per-agent web UI returns +/// from a `Recent` request. Lives in `hive_sh4re` so it can travel +/// over both the dashboard's `/api/state` and the agent socket +/// without an internal-to-wire conversion. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InboxRow { + pub id: i64, + pub from: String, + pub body: String, + pub at: i64, +} + /// Requests on a per-agent socket. The agent's identity is the socket /// it came in on; `Send.from` is filled in by the server, not the client. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -163,6 +176,10 @@ pub enum AgentRequest { /// per-agent equivalent of the old dashboard T4LK form, but scoped to /// the agent whose page the operator is on. OperatorMsg { body: String }, + /// Last `limit` messages addressed to this agent, newest-first. + /// Non-mutating — pulls from the broker without delivering. The + /// per-agent web UI uses this to render its own inbox section. + Recent { limit: u64 }, } /// Responses on a per-agent socket. @@ -179,6 +196,8 @@ pub enum AgentResponse { Empty, /// `Status` result: how many pending messages are in this agent's inbox. Status { unread: u64 }, + /// `Recent` result: newest-first inbox rows. + Recent { rows: Vec }, } // ----------------------------------------------------------------------------- @@ -264,6 +283,11 @@ pub enum ManagerRequest { OperatorMsg { body: String, }, + /// Last `limit` messages addressed to the manager, newest-first. + /// Non-mutating; mirror of `AgentRequest::Recent`. + Recent { + limit: u64, + }, /// Submit a spawn request for the user to approve. On approval the host /// creates and starts the container. Brand-new agent names only — if an /// agent of the same name already exists, the approval will fail. @@ -329,4 +353,8 @@ pub enum ManagerResponse { QuestionQueued { id: i64, }, + /// `Recent` result: mirror of `AgentResponse::Recent`. + Recent { + rows: Vec, + }, } From 2146e47770d349fc58a0efc1a3160b8f31a1dc99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 20:33:51 +0200 Subject: [PATCH 5/6] web ui: retry binding on AddrInUse during restart races MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit operator hit 'Address already in use (os error 98)' on a harness restart — the new harness raced the old socket's release. add a bind_with_retry helper that backs off (250ms doubling, capped at 2s, 12 tries ≈ 22s total) on AddrInUse before giving up. applied to both the per-agent web UI and the hive-c0re dashboard. proper fix would be SO_REUSEADDR via socket2 but retry covers the TIME_WAIT case fine and keeps the dep count down. Other bind errors still fail immediately (port permission, fd exhaustion). --- hive-ag3nt/src/web_ui.rs | 31 ++++++++++++++++++++++++++++--- hive-c0re/src/dashboard.rs | 28 +++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 92288577..688f7a03 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -83,9 +83,7 @@ pub async fn serve( .route("/api/compact", post(post_compact)) .with_state(state); let addr = SocketAddr::from(([0, 0, 0, 0], port)); - let listener = tokio::net::TcpListener::bind(addr) - .await - .with_context(|| format!("bind web UI on port {port}"))?; + let listener = bind_with_retry(addr, "web UI").await?; tracing::info!(%port, "web UI listening"); axum::serve(listener, app).await?; Ok(()) @@ -95,6 +93,33 @@ pub async fn serve( // Static assets + state snapshot // --------------------------------------------------------------------------- +/// Bind a TCP listener, retrying on `AddrInUse` for up to ~20s. +/// nspawn restarts can race the previous harness's socket release; +/// without retry the new harness fails to bind and systemd just +/// keeps restarting it. `SO_REUSEADDR` would be the proper fix but +/// would require socket2; retry is good enough here. +async fn bind_with_retry(addr: SocketAddr, label: &str) -> Result { + let mut delay_ms = 250u64; + let mut attempts = 0u32; + loop { + match tokio::net::TcpListener::bind(addr).await { + Ok(l) => return Ok(l), + Err(e) if e.kind() == std::io::ErrorKind::AddrInUse && attempts < 12 => { + tracing::warn!( + %addr, attempt = attempts + 1, + "{label}: AddrInUse, retrying in {delay_ms}ms" + ); + tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; + attempts += 1; + delay_ms = (delay_ms * 2).min(2000); + } + Err(e) => { + return Err(e).with_context(|| format!("bind {label} on {addr}")); + } + } + } +} + async fn serve_index() -> impl IntoResponse { ( [("content-type", "text/html; charset=utf-8")], diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index eed8f9b4..1165e990 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -57,9 +57,7 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/messages/stream", get(messages_stream)) .with_state(AppState { coord }); let addr = SocketAddr::from(([0, 0, 0, 0], port)); - let listener = tokio::net::TcpListener::bind(addr) - .await - .with_context(|| format!("bind dashboard on port {port}"))?; + let listener = bind_with_retry(addr).await?; tracing::info!(%port, "dashboard listening"); axum::serve(listener, app).await?; Ok(()) @@ -73,6 +71,30 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { // `/messages/stream` for broker traffic. // --------------------------------------------------------------------------- +/// Retry-on-AddrInUse bind. Same shape as the per-agent variant — +/// hive-c0re restarts also race the previous process's socket release. +async fn bind_with_retry(addr: SocketAddr) -> Result { + let mut delay_ms = 250u64; + let mut attempts = 0u32; + loop { + match tokio::net::TcpListener::bind(addr).await { + Ok(l) => return Ok(l), + Err(e) if e.kind() == std::io::ErrorKind::AddrInUse && attempts < 12 => { + tracing::warn!( + %addr, attempt = attempts + 1, + "dashboard: AddrInUse, retrying in {delay_ms}ms" + ); + tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; + attempts += 1; + delay_ms = (delay_ms * 2).min(2000); + } + Err(e) => { + return Err(e).with_context(|| format!("bind dashboard on {addr}")); + } + } + } +} + async fn serve_index() -> impl IntoResponse { Html(include_str!("../assets/index.html")) } From 754db7830e099956f819422231a29d013291f2c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 20:38:02 +0200 Subject: [PATCH 6/6] ask_operator: ttl_seconds auto-cancel + remaining-time chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manager can pass ttl_seconds to ask_operator. on submit, host stores deadline_at = now + ttl in operator_questions (new column, migrated via existing pragma_table_info pattern), spawns a tokio task that sleeps until the deadline then resolves the question with answer '[expired]' and fires the same OperatorAnswered helper event. already-resolved races no-op silently. dashboard renders a '⏳ MM:SS' chip on the question row when deadline_at is set. format collapses seconds → s, < 1h → m s, ≥ 1h → h m. heartbeat refresh (5s) keeps the chip current; the operator sees it tick down. manager prompt + mcp tool description updated. journald viewer per container queued in todo (separate task). --- TODO.md | 18 +++++---- hive-ag3nt/prompts/manager.md | 2 +- hive-ag3nt/src/mcp.rs | 11 +++++- hive-c0re/assets/app.js | 23 ++++++++---- hive-c0re/assets/dashboard.css | 6 +++ hive-c0re/src/manager_server.rs | 45 ++++++++++++++++++++-- hive-c0re/src/operator_questions.rs | 58 +++++++++++++++++++++-------- hive-sh4re/src/lib.rs | 6 +++ 8 files changed, 133 insertions(+), 36 deletions(-) diff --git a/TODO.md b/TODO.md index 1896fede..a84af5f5 100644 --- a/TODO.md +++ b/TODO.md @@ -68,13 +68,6 @@ Pick anything from here when relevant. Cross-cutting design notes live in ## Manager → operator question channel -- **TTL on `ask_operator`.** Manual cancel via dashboard already - ships (✗ CANC3L button resolves the question with answer - `[cancelled]` and fires `OperatorAnswered` so the manager sees a - terminal state). Still missing: per-question `ttl_seconds` that - auto-cancels after a deadline. Spawn a tokio task per submitted - question that calls the same cancel path after the ttl expires - (cheap; rare). Surface remaining time on the dashboard. ## Spawn flow @@ -114,6 +107,17 @@ Pick anything from here when relevant. Cross-cutting design notes live in ## Lifecycle / reliability +- **journald viewer per container in the dashboard.** Surface the + equivalent of `journalctl -M h-coder -b` in the dashboard so the + operator can see container logs without ssh-ing in. Optional + filter by hive-specific systemd unit (`hive-ag3nt.service`, + `hive-m1nd.service`). Implementation: backend shells out to + `journalctl -M -b --output=short-iso --no-pager` + (optionally `-u `), streams or paginates the result over a + new dashboard endpoint. Could be a `
` per container row + or a dedicated page. Honest journalctl, not the in-container + events stream — those are different surfaces (events = claude turn + loop; journalctl = systemd-wide logs incl. boot, network, etc.). - **Container crash events.** Watch `container@*.service` via D-Bus, push `HelperEvent::ContainerCrash` to the manager's inbox so the manager can react (restart, escalate, etc.). diff --git a/hive-ag3nt/prompts/manager.md b/hive-ag3nt/prompts/manager.md index 15b82f36..ca96cae8 100644 --- a/hive-ag3nt/prompts/manager.md +++ b/hive-ag3nt/prompts/manager.md @@ -9,7 +9,7 @@ Tools (hyperhive surface): - `mcp__hyperhive__start(name)` — start a stopped sub-agent. No approval required. - `mcp__hyperhive__restart(name)` — stop + start a sub-agent. No approval required. - `mcp__hyperhive__request_apply_commit(agent, commit_ref)` — submit a config change for any agent (`hm1nd` for self) for operator approval. -- `mcp__hyperhive__ask_operator(question, options?, multi?)` — surface a question on the dashboard. Returns immediately with a question id; the operator's answer arrives later as a system `operator_answered` event in your inbox. Options are advisory: the dashboard always lets the operator type a free-text answer in addition. Set `multi: true` to render options as checkboxes (operator can pick multiple); the answer comes back as `, `-separated. Do not poll inside the same turn — finish the current work and react when the event lands. +- `mcp__hyperhive__ask_operator(question, options?, multi?, ttl_seconds?)` — surface a question on the dashboard. Returns immediately with a question id; the operator's answer arrives later as a system `operator_answered` event in your inbox. Options are advisory: the dashboard always lets the operator type a free-text answer in addition. Set `multi: true` to render options as checkboxes (operator can pick multiple); the answer comes back as `, `-separated. Set `ttl_seconds` to auto-cancel after a deadline — useful when the decision becomes moot if the operator hasn't responded in time; on expiry the answer is `[expired]`. Do not poll inside the same turn — finish the current work and react when the event lands. Approval boundary: lifecycle ops on *existing* sub-agents (`kill`, `start`, `restart`) are at your discretion — no operator approval. *Creating* a new agent (`request_spawn`) and *changing* any agent's config (`request_apply_commit`) still go through the approval queue. The operator only signs off on changes; you run the day-to-day. diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index cf1d76b3..d47a021a 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -240,6 +240,12 @@ pub struct AskOperatorArgs { /// selections joined by ", ". Ignored when `options` is empty. #[serde(default)] pub multi: bool, + /// Optional auto-cancel after `ttl_seconds`. On expiry the question + /// resolves with answer `[expired]` and the manager receives the + /// usual `operator_answered` system event. `None` (default) = + /// wait indefinitely. + #[serde(default)] + pub ttl_seconds: Option, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] @@ -380,7 +386,9 @@ impl ManagerServer { request, policy call, scope clarification). `options` is advisory: pass a short \ fixed-choice list when applicable, otherwise leave empty for free text. Set \ `multi: true` to let the operator pick multiple options (checkboxes); the answer \ - comes back as a comma-separated string." + comes back as a comma-separated string. Set `ttl_seconds` to auto-cancel a \ + no-longer-relevant question instead of blocking forever — on expiry the answer \ + is `[expired]` and the same `operator_answered` event fires." )] async fn ask_operator(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); @@ -390,6 +398,7 @@ impl ManagerServer { question: args.question, options: args.options, multi: args.multi, + ttl_seconds: args.ttl_seconds, }) .await; match resp { diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index feb1926d..61ee03e5 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -237,14 +237,23 @@ const ul = el('ul', { class: 'questions' }); for (const q of s.questions) { const li = el('li', { class: 'question' }); - li.append( - el('div', { class: 'q-head' }, - el('span', { class: 'msg-ts' }, fmt(q.asked_at)), ' ', - el('span', { class: 'msg-from' }, q.asker), ' ', - el('span', { class: 'msg-sep' }, 'asks:'), - ), - el('div', { class: 'q-body' }, q.question), + const head = el('div', { class: 'q-head' }, + el('span', { class: 'msg-ts' }, fmt(q.asked_at)), ' ', + el('span', { class: 'msg-from' }, q.asker), ' ', + el('span', { class: 'msg-sep' }, 'asks:'), ); + if (q.deadline_at) { + const remaining = q.deadline_at - Math.floor(Date.now() / 1000); + let txt; + if (remaining <= 0) txt = 'expiring…'; + else if (remaining < 60) txt = '⏳ ' + remaining + 's'; + else if (remaining < 3600) txt = '⏳ ' + Math.floor(remaining / 60) + 'm ' + + (remaining % 60) + 's'; + else txt = '⏳ ' + Math.floor(remaining / 3600) + 'h ' + + Math.floor((remaining % 3600) / 60) + 'm'; + head.append(' ', el('span', { class: 'q-ttl' }, txt)); + } + li.append(head, el('div', { class: 'q-body' }, q.question)); const f = el('form', { method: 'POST', action: '/answer-question/' + q.id, class: 'qform', 'data-async': '', diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index 5fc4c4ef..b48cac30 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -296,6 +296,12 @@ summary:hover { color: var(--purple); } } .questions li.question:last-child { border-bottom: 0; } .questions .q-head { font-size: 0.9em; } +.questions .q-ttl { + color: var(--amber); + margin-left: 0.4em; + font-size: 0.95em; + letter-spacing: 0.05em; +} .questions .q-body { color: var(--fg); margin: 0.3em 0; diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index efefd64b..d946b276 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -72,7 +72,7 @@ async fn serve(stream: UnixStream, coord: Arc) -> Result<()> { const MANAGER_RECV_LONG_POLL: std::time::Duration = std::time::Duration::from_secs(30); #[allow(clippy::too_many_lines)] -async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse { +async fn dispatch(req: &ManagerRequest, coord: &Arc) -> ManagerResponse { match req { ManagerRequest::Send { to, body } => match coord.broker.send(&Message { from: MANAGER_AGENT.to_owned(), @@ -198,14 +198,26 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse question, options, multi, + ttl_seconds, } => { - tracing::info!(%question, ?options, multi, "manager: ask_operator"); + tracing::info!(%question, ?options, multi, ?ttl_seconds, "manager: ask_operator"); + let deadline_at = ttl_seconds.and_then(|s| { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .and_then(|d| i64::try_from(d.as_secs()).ok()) + .unwrap_or(0); + i64::try_from(s).ok().map(|s| now + s) + }); match coord .questions - .submit(MANAGER_AGENT, question, options, *multi) + .submit(MANAGER_AGENT, question, options, *multi, deadline_at) { Ok(id) => { - tracing::info!(%id, "operator question queued"); + tracing::info!(%id, ?deadline_at, "operator question queued"); + if let Some(ttl) = *ttl_seconds { + spawn_question_watchdog(coord, id, ttl); + } ManagerResponse::QuestionQueued { id } } Err(e) => ManagerResponse::Err { @@ -227,3 +239,28 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse } } } + +/// On `AskOperator { ttl_seconds: Some(n) }`, sleep n seconds and then +/// try to resolve the question with `[expired]`. If the operator (or +/// any other path) already answered it, `answer()` returns Err and +/// we no-op silently. Otherwise fire the usual `OperatorAnswered` +/// helper event so the manager sees a terminal state. +const TTL_SENTINEL: &str = "[expired]"; + +fn spawn_question_watchdog(coord: &Arc, id: i64, ttl_secs: u64) { + let coord = coord.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(ttl_secs)).await; + // `answer` returns Err if already resolved — that's the + // normal path when the operator responded before the ttl + // fired, so no-op silently. + if let Ok(question) = coord.questions.answer(id, TTL_SENTINEL) { + tracing::info!(%id, "operator question expired (ttl)"); + coord.notify_manager(&hive_sh4re::HelperEvent::OperatorAnswered { + id, + question, + answer: TTL_SENTINEL.to_owned(), + }); + } + }); +} diff --git a/hive-c0re/src/operator_questions.rs b/hive-c0re/src/operator_questions.rs index 7ece4fcf..4ca652ed 100644 --- a/hive-c0re/src/operator_questions.rs +++ b/hive-c0re/src/operator_questions.rs @@ -25,17 +25,29 @@ CREATE INDEX IF NOT EXISTS idx_operator_questions_pending ON operator_questions (id) WHERE answered_at IS NULL; "; -/// Add the `multi` column to pre-existing databases. `ALTER TABLE ADD COLUMN` -/// has no `IF NOT EXISTS` form in sqlite, so we check `pragma_table_info` first. -fn ensure_multi_column(conn: &Connection) -> Result<()> { - let has: bool = conn - .prepare("SELECT 1 FROM pragma_table_info('operator_questions') WHERE name = 'multi'")? - .exists([])?; - if !has { - conn.execute_batch( +/// Add late-added columns to pre-existing databases. `ALTER TABLE +/// ADD COLUMN` has no `IF NOT EXISTS` form in sqlite, so we check +/// `pragma_table_info` first per column. +fn ensure_columns(conn: &Connection) -> Result<()> { + for (name, sql) in [ + ( + "multi", "ALTER TABLE operator_questions ADD COLUMN multi INTEGER NOT NULL DEFAULT 0;", - ) - .context("add operator_questions.multi column")?; + ), + ( + "deadline_at", + "ALTER TABLE operator_questions ADD COLUMN deadline_at INTEGER;", + ), + ] { + let has: bool = conn + .prepare(&format!( + "SELECT 1 FROM pragma_table_info('operator_questions') WHERE name = '{name}'" + ))? + .exists([])?; + if !has { + conn.execute_batch(sql) + .with_context(|| format!("add operator_questions.{name} column"))?; + } } Ok(()) } @@ -49,6 +61,10 @@ pub struct OpQuestion { pub options: Vec, pub multi: bool, pub asked_at: i64, + /// Absolute unix-seconds deadline after which a watchdog auto- + /// resolves the question with answer `[expired]`. `None` = no + /// expiry. Surfaced on the dashboard as a remaining-time chip. + pub deadline_at: Option, pub answered_at: Option, pub answer: Option, } @@ -68,7 +84,7 @@ impl OperatorQuestions { .with_context(|| format!("open operator_questions db {}", path.display()))?; conn.execute_batch(SCHEMA) .context("apply operator_questions schema")?; - ensure_multi_column(&conn).context("migrate operator_questions.multi")?; + ensure_columns(&conn).context("migrate operator_questions columns")?; Ok(Self { conn: Mutex::new(conn), }) @@ -80,13 +96,22 @@ impl OperatorQuestions { question: &str, options: &[String], multi: bool, + deadline_at: Option, ) -> Result { let conn = self.conn.lock().unwrap(); let options_json = serde_json::to_string(options).unwrap_or_else(|_| "[]".into()); conn.execute( - "INSERT INTO operator_questions (asker, question, options_json, multi, asked_at) - VALUES (?1, ?2, ?3, ?4, ?5)", - params![asker, question, options_json, i64::from(multi), now_unix()], + "INSERT INTO operator_questions + (asker, question, options_json, multi, deadline_at, asked_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + asker, + question, + options_json, + i64::from(multi), + deadline_at, + now_unix(), + ], )?; Ok(conn.last_insert_rowid()) } @@ -119,7 +144,7 @@ impl OperatorQuestions { pub fn get(&self, id: i64) -> Result> { let conn = self.conn.lock().unwrap(); conn.query_row( - "SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer + "SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at FROM operator_questions WHERE id = ?1", params![id], row_to_question, @@ -131,7 +156,7 @@ impl OperatorQuestions { pub fn pending(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer + "SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at FROM operator_questions WHERE answered_at IS NULL ORDER BY id ASC", @@ -155,6 +180,7 @@ fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result { asked_at: row.get(5)?, answered_at: row.get(6)?, answer: row.get(7)?, + deadline_at: row.get(8)?, }) } diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 56eead0d..52f80004 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -323,12 +323,18 @@ pub enum ManagerRequest { /// - `multi=true` lets the operator pick multiple options (rendered /// as checkboxes). The answer is returned as a single string with /// selections joined by ", ". + /// - `ttl_seconds`: optional auto-cancel after that many seconds. On + /// expiry the question is resolved with answer `[expired]` and the + /// manager gets the usual `OperatorAnswered` event. None = wait + /// forever for an operator answer (or manual cancel). AskOperator { question: String, #[serde(default)] options: Vec, #[serde(default)] multi: bool, + #[serde(default)] + ttl_seconds: Option, }, }