From 9bc47f9ce319866a1ed826ca7cf15efbe1a5d0ac Mon Sep 17 00:00:00 2001 From: jenkins Date: Sun, 16 Aug 2026 01:10:47 -0300 Subject: [PATCH] hermes: keep durable worker activity visible --- dockerfiles/Dockerfile.hermes-agent | 125 ++++++++++++++++ dockerfiles/hermes-session-activity-panel.tsx | 133 +++++++++++++---- services/hermes/agent-configmap.yaml | 14 +- services/hermes/agent-deployment.yaml | 14 ++ services/hermes/kustomization.yaml | 2 +- .../scripts/patch_api_server_sessions.py | 52 ++++++- .../scripts/patch_web_session_activity.py | 133 ++++++++++++++++- testing/tests/test_hermes_chat_quality.py | 134 ++++++++++++++++++ testing/tests/test_hermes_cli_lanes.py | 9 ++ 9 files changed, 579 insertions(+), 37 deletions(-) diff --git a/dockerfiles/Dockerfile.hermes-agent b/dockerfiles/Dockerfile.hermes-agent index 108ce940..393b8851 100644 --- a/dockerfiles/Dockerfile.hermes-agent +++ b/dockerfiles/Dockerfile.hermes-agent @@ -856,6 +856,83 @@ function replaceOnce(source, before, after, label) { "", "xterm WebGL import", ); + source = replaceOnce( + source, + ' const resumeParam = searchParams.get("resume");\n', + ' const resumeParam = searchParams.get("resume");\n' + + ' const lineageRoot = searchParams.get("lineage_root") || resumeParam;\n', + "durable lineage root", + ); + source = replaceOnce( + source, + String.raw` useEffect(() => { + if (!resumeParam) return; + + let cancelled = false; + + api + .getSessionLatestDescendant(resumeParam, scopedProfile) + .then((res) => { + if (cancelled || !res.session_id || res.session_id === resumeParam) { + return; + } + + const next = new URLSearchParams(searchParams); + next.set("resume", res.session_id); + setSearchParams(next, { replace: true }); + }) + .catch(() => { + // Best-effort: old servers or missing sessions should not block chat. + }); + + return () => { + cancelled = true; + }; + }, [resumeParam, scopedProfile, searchParams, setSearchParams]); +`, + String.raw` useEffect(() => { + if (!lineageRoot) return; + + let cancelled = false; + const followActiveLineage = async () => { + try { + const res = await api.getSessionLatestDescendant( + lineageRoot, + scopedProfile, + ); + if (cancelled || !res.session_id) return; + setSearchParams( + (previous) => { + const next = new URLSearchParams(previous); + const current = next.get("resume"); + if (current === res.session_id) return previous; + next.set("lineage_root", lineageRoot); + next.set("resume", res.session_id); + return next; + }, + { replace: true }, + ); + } catch { + // Best-effort: old servers or missing sessions should not block chat. + } + }; + + void followActiveLineage(); + const timer = window.setInterval(() => void followActiveLineage(), 2_000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [lineageRoot, scopedProfile, setSearchParams]); +`, + "active lineage follower", + ); + source = replaceOnce( + source, + ' next.delete("resume");\n', + ' next.delete("resume");\n next.delete("lineage_root");\n', + "fresh chat lineage reset", + ); const webglStart = source.indexOf(" // WebGL draws from"); const webglEnd = source.indexOf( "\n\n // Initial fit + resize observer", @@ -993,6 +1070,40 @@ function replaceOnce(source, before, after, label) { ),`, "session child query option", ); + source = replaceOnce( + source, + `export interface SessionMessagesResponse { + session_id: string; + messages: SessionMessage[]; +}`, + `export interface SessionMessagesResponse { + session_id: string; + messages: SessionMessage[]; + total_messages?: number; +}`, + "session message total", + ); + source = replaceOnce( + source, + ` getSessionMessages: (id: string, profile = getManagementProfile()) => + fetchJSON( + appendProfileParam(\`/api/sessions/\${encodeURIComponent(id)}/messages\`, profile), + ),`, + ` getSessionMessages: ( + id: string, + profile = getManagementProfile(), + limit?: number, + ) => + fetchJSON( + appendProfileParam( + \`/api/sessions/\${encodeURIComponent(id)}/messages\${ + limit ? \`?limit=\${Math.max(1, Math.floor(limit))}\` : "" + }\`, + profile, + ), + ),`, + "bounded session message query", + ); fs.writeFileSync(path, source); } @@ -1019,6 +1130,18 @@ function replaceOnce(source, before, after, label) { '.getSessions(SESSION_LIMIT, 0, scopeKey, "recent", true)', "session-tree child query", ); + source = replaceOnce( + source, + ' next.set("resume", id);\n', + ' next.delete("lineage_root");\n next.set("resume", id);\n', + "manual session lineage reset", + ); + source = replaceOnce( + source, + ' next.delete("resume");\n', + ' next.delete("resume");\n next.delete("lineage_root");\n', + "new session lineage reset", + ); source = replaceOnce( source, " const [reloadNonce, setReloadNonce] = useState(0);\n", @@ -1236,6 +1359,8 @@ RUN cd /opt/hermes/web \ && grep -Fq 'api.getSessions(1, 0' src/components/ChatSidebar.tsx \ && grep -Fq 'if (unmounting) return;' src/pages/ChatPage.tsx \ && grep -Fq 'resume:${resumeParam}' src/pages/ChatPage.tsx \ + && grep -Fq 'followActiveLineage' src/pages/ChatPage.tsx \ + && grep -Fq 'lineage_root' src/components/ChatSessionList.tsx \ && grep -Fq 'eventsRetryAttempt.current' src/components/ChatSidebar.tsx \ && grep -Fq 'reconnecting…' src/components/ChatSidebar.tsx \ && grep -Fq 'redirectToProxyLogin' src/lib/api.ts \ diff --git a/dockerfiles/hermes-session-activity-panel.tsx b/dockerfiles/hermes-session-activity-panel.tsx index f2be9ddc..63c857f2 100644 --- a/dockerfiles/hermes-session-activity-panel.tsx +++ b/dockerfiles/hermes-session-activity-panel.tsx @@ -17,6 +17,7 @@ import { } from "@/lib/api"; const POLL_MS = 2_000; +const DEFAULT_VISIBLE_MESSAGES = 250; interface SessionActivityPanelProps { sessionId: string; @@ -29,7 +30,7 @@ function messageLabel(message: SessionMessage): string { } if (message.tool_calls?.length) { const names = message.tool_calls - .map((call) => call.function.name) + .map((call) => call?.function?.name) .filter(Boolean) .join(", "); return names ? `Tool calls: ${names}` : "Tool calls"; @@ -37,6 +38,43 @@ function messageLabel(message: SessionMessage): string { return message.role === "assistant" ? "Hermes" : message.role; } +function displayText(value: unknown): string { + if (typeof value === "string") return value; + if (value == null) return ""; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function ExpandablePayload({ + payload, + summary, + openByDefault = false, +}: { + payload: string; + summary: string; + openByDefault?: boolean; +}) { + const [open, setOpen] = useState(openByDefault); + + return ( +
setOpen(event.currentTarget.open)} + > + {summary} + {open && ( +
+          {payload}
+        
+ )} +
+ ); +} + function latestTimestamp(messages: SessionMessage[]): number | null { let latest: number | null = null; for (const message of messages) { @@ -53,8 +91,8 @@ function ActivityMessage({ message: SessionMessage; index: number; }) { - const toolCalls = message.tool_calls ?? []; - const content = message.content ?? ""; + const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []; + const content = displayText(message.content); const isToolResult = message.role === "tool"; return ( @@ -71,26 +109,23 @@ function ActivityMessage({ )} - {toolCalls.map((call) => ( -
- - {call.function.name || "tool"} arguments - -
-            {call.function.arguments}
-          
-
- ))} + {toolCalls.map((call, callIndex) => { + const name = call?.function?.name || "tool"; + return ( + + ); + })} {content && isToolResult ? ( -
- - {content.length.toLocaleString()} characters of output - -
-            {content}
-          
-
+ ) : content ? (
           {content}
@@ -114,7 +149,9 @@ export function SessionActivityPanel({
 }: SessionActivityPanelProps) {
   const [detail, setDetail] = useState(null);
   const [messages, setMessages] = useState([]);
+  const [totalMessages, setTotalMessages] = useState(0);
   const [expanded, setExpanded] = useState(true);
+  const [visibleLimit, setVisibleLimit] = useState(DEFAULT_VISIBLE_MESSAGES);
   const [error, setError] = useState(null);
   const [pollTime, setPollTime] = useState(Date.now());
   const requestRunning = useRef(false);
@@ -127,10 +164,16 @@ export function SessionActivityPanel({
     try {
       const [nextDetail, response] = await Promise.all([
         api.getSessionDetail(sessionId, profile ?? ""),
-        api.getSessionMessages(sessionId, profile ?? ""),
+        api.getSessionMessages(sessionId, profile ?? "", visibleLimit),
       ]);
       setDetail(nextDetail);
-      setMessages(Array.isArray(response.messages) ? response.messages : []);
+      const nextMessages = Array.isArray(response.messages) ? response.messages : [];
+      setMessages(nextMessages);
+      setTotalMessages(
+        typeof response.total_messages === "number"
+          ? response.total_messages
+          : nextMessages.length,
+      );
       setError(null);
       setPollTime(Date.now());
       if (stickToBottom.current) {
@@ -144,6 +187,10 @@ export function SessionActivityPanel({
     } finally {
       requestRunning.current = false;
     }
+  }, [profile, sessionId, visibleLimit]);
+
+  useEffect(() => {
+    setVisibleLimit(DEFAULT_VISIBLE_MESSAGES);
   }, [profile, sessionId]);
 
   useEffect(() => {
@@ -160,6 +207,14 @@ export function SessionActivityPanel({
   }, [load]);
 
   const latest = useMemo(() => latestTimestamp(messages), [messages]);
+  const visibleMessages = useMemo(
+    () =>
+      messages.length <= visibleLimit
+        ? messages
+        : messages.slice(-visibleLimit),
+    [messages, visibleLimit],
+  );
+  const hiddenMessageCount = Math.max(0, totalMessages - visibleMessages.length);
   const quietSeconds = latest === null ? null : Math.max(0, pollTime / 1000 - latest);
   const running = detail?.ended_at == null;
   const status = error
@@ -199,7 +254,8 @@ export function SessionActivityPanel({
             aria-live="polite"
             aria-atomic="true"
           >
-            {status} · {messages.length} recorded events
+            {status} · {messages.length.toLocaleString()} of{" "}
+            {totalMessages.toLocaleString()} recorded events loaded
           
         
         
@@ -245,6 +301,29 @@ export function SessionActivityPanel({ node.scrollHeight - node.scrollTop - node.clientHeight < 80; }} > + {hiddenMessageCount > 0 && ( +
+ +
+ )} {messages.length === 0 ? (
@@ -254,11 +333,11 @@ export function SessionActivityPanel({
) : ( - messages.map((message, index) => ( + visibleMessages.map((message, index) => ( )) )} diff --git a/services/hermes/agent-configmap.yaml b/services/hermes/agent-configmap.yaml index 9d59bdee..44e4f468 100644 --- a/services/hermes/agent-configmap.yaml +++ b/services/hermes/agent-configmap.yaml @@ -279,12 +279,14 @@ data: ## Atlas engineering access - Atlas repositories are private and canonical at - `https://scm.bstein.dev/atlas/.git`. HTTPS Git authentication is - already supplied through `GIT_ASKPASS`. Verify the remote and cleanly - separate pre-existing changes, create a task branch, run the repository's - tests, use `git push --dry-run` when proving access, and push a real branch - only when the requested implementation is review-ready. Never force-push. + The Atlas organization has private visibility. Its repositories are access- + controlled as either private or Gitea-internal, never public, and are + canonical at `https://scm.bstein.dev/atlas/.git`. HTTPS Git + authentication is already supplied through `GIT_ASKPASS`. Verify the remote + and cleanly separate pre-existing changes, create a task branch, run the + repository's tests, use `git push --dry-run` when proving access, and push a + real branch only when the requested implementation is review-ready. Never + force-push. The terminal PATH contains the pinned operator tools. Start cluster work with `kubectl config current-context`, read-only status/events/logs, and the diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml index 4b1d40ce..4a8c70de 100644 --- a/services/hermes/agent-deployment.yaml +++ b/services/hermes/agent-deployment.yaml @@ -125,6 +125,7 @@ spec: - | set -eu env_file=/opt/data/.env + profile_file=/opt/data/home/.profile mkdir -p \ /opt/data/home/.claude \ /opt/data/home/.codex \ @@ -184,6 +185,18 @@ spec: upsert_env GIT_ASKPASS /opt/coordinator/gitea_askpass.sh upsert_env GIT_TERMINAL_PROMPT 0 chmod 0600 "${env_file}" + touch "${profile_file}" + if ! grep -qxF '# Hermes managed operator PATH.' "${profile_file}"; then + printf '%s\n' \ + '' \ + '# Hermes managed operator PATH.' \ + 'case ":${PATH}:" in' \ + ' *":/opt/data/tools/bin:"*) ;;' \ + ' *) PATH="/opt/coordinator:/opt/data/tools/bin:/opt/hermes/.venv/bin:/opt/hermes/bin:${PATH}" ;;' \ + 'esac' \ + 'export PATH' >> "${profile_file}" + fi + chmod 0644 "${profile_file}" # Existing owner data is already written as uid/gid 10000. A # recursive chown made every routine rollout walk the full 20Gi # workspace while the dashboard had no endpoint. Own only the @@ -208,6 +221,7 @@ spec: /opt/data/SOUL.md \ /opt/data/workspace/AGENTS.md \ /opt/data/workspace/START-HERE.md \ + "${profile_file}" \ "${env_file}" securityContext: allowPrivilegeEscalation: false diff --git a/services/hermes/kustomization.yaml b/services/hermes/kustomization.yaml index f7601e27..b6669b6d 100644 --- a/services/hermes/kustomization.yaml +++ b/services/hermes/kustomization.yaml @@ -4,7 +4,7 @@ kind: Kustomization namespace: hermes images: - name: registry.bstein.dev/bstein/hermes-agent - digest: sha256:611c1642381d2a82ff03b55dda58bcab9a2e723c495dc298d1e9f938e94ed8d4 + digest: sha256:de9463fab178916ff9c880af2f8dae7ffc77585e3f7b73e15f5369454ce0f3dd resources: - namespace.yaml - vault-serviceaccount.yaml diff --git a/services/hermes/scripts/patch_api_server_sessions.py b/services/hermes/scripts/patch_api_server_sessions.py index ce5c93a5..44e3d52e 100644 --- a/services/hermes/scripts/patch_api_server_sessions.py +++ b/services/hermes/scripts/patch_api_server_sessions.py @@ -159,6 +159,7 @@ EVENT_CALLBACK_SIGNATURE_BEFORE = ''' def _make_run_event_callback(self, run_ EVENT_CALLBACK_SIGNATURE_AFTER = ''' _RUN_ACTIVITY_BYTES = 512_000 _RUN_ACTIVITY_FILES = 256 + _RUN_ACTIVITY_HEARTBEAT_SECONDS = 15.0 def _record_run_activity( self, @@ -189,6 +190,34 @@ EVENT_CALLBACK_SIGNATURE_AFTER = ''' _RUN_ACTIVITY_BYTES = 512_000 label = labels.get(event_type) if not session_id or not label: return + now = time.time() + if event_type in { + "_thinking", + "reasoning.available", + "subagent.progress", + "subagent_progress", + "subagent.text", + "subagent.thinking", + }: + # Streaming providers may emit hundreds of token-level progress + # callbacks. They carry no displayable detail here; keep one live + # heartbeat per session interval while retaining every persisted + # assistant message, tool call, and tool result in the session DB. + heartbeats = getattr(self, "_run_activity_heartbeats", None) + if heartbeats is None: + heartbeats = {} + self._run_activity_heartbeats = heartbeats + last_heartbeat = float(heartbeats.get(session_id, 0.0)) + if now - last_heartbeat < self._RUN_ACTIVITY_HEARTBEAT_SECONDS: + return + heartbeats[session_id] = now + if len(heartbeats) > self._RUN_ACTIVITY_FILES * 2: + cutoff = now - 3_600.0 + self._run_activity_heartbeats = { + key: value + for key, value in heartbeats.items() + if value >= cutoff + } # Tool names are bounded identifiers. All previews are arbitrary model # or tool text and can contain prompts, arguments, paths, or credentials, # so the activity projection must never persist them. @@ -203,7 +232,7 @@ EVENT_CALLBACK_SIGNATURE_AFTER = ''' _RUN_ACTIVITY_BYTES = 512_000 entry = { "role": "assistant", "content": f"Activity · {content}", - "timestamp": time.time(), + "timestamp": now, "activity_event": event_type, } root = Path( @@ -304,6 +333,25 @@ EVENT_CALLBACK_CALL_AFTER = ''' event_cb = self._make_run_event_callback( self._record_run_activity(session_id, "run.started") ''' +RUN_SWEEP_BEFORE = ''' self._run_streams.pop(run_id, None) + self._run_streams_created.pop(run_id, None) + self._active_run_agents.pop(run_id, None) + self._active_run_tasks.pop(run_id, None) + self._run_approval_sessions.pop(run_id, None) +''' + +RUN_SWEEP_AFTER = ''' self._run_streams.pop(run_id, None) + self._run_streams_created.pop(run_id, None) + # Stream retention and run lifetime are separate. A long run + # can legitimately outlive its unconsumed SSE queue; keep the + # control handles so /stop and approval resolution still work. + terminal_status = self._run_statuses.get(run_id, {}).get("status") + if terminal_status in {"completed", "failed", "cancelled"}: + self._active_run_agents.pop(run_id, None) + self._active_run_tasks.pop(run_id, None) + self._run_approval_sessions.pop(run_id, None) +''' + def patch(source: Path, destination: Path) -> None: """Apply the narrow session-lineage extension and fail on upstream drift.""" @@ -319,6 +367,7 @@ def patch(source: Path, destination: Path) -> None: (EVENT_CALLBACK_BODY_BEFORE, "event callback body"), (EVENT_CALLBACK_END_BEFORE, "event callback end"), (EVENT_CALLBACK_CALL_BEFORE, "event callback call"), + (RUN_SWEEP_BEFORE, "run stream sweep"), ): if marker not in content: raise RuntimeError(f"Hermes API {message} patch context changed") @@ -333,6 +382,7 @@ def patch(source: Path, destination: Path) -> None: ) content = content.replace(EVENT_CALLBACK_BODY_BEFORE, EVENT_CALLBACK_BODY_AFTER, 1) content = content.replace(EVENT_CALLBACK_END_BEFORE, EVENT_CALLBACK_END_AFTER, 1) + content = content.replace(RUN_SWEEP_BEFORE, RUN_SWEEP_AFTER, 1) destination.write_text( content.replace(EVENT_CALLBACK_CALL_BEFORE, EVENT_CALLBACK_CALL_AFTER, 1), encoding="utf-8", diff --git a/services/hermes/scripts/patch_web_session_activity.py b/services/hermes/scripts/patch_web_session_activity.py index 8d449764..cc729b91 100644 --- a/services/hermes/scripts/patch_web_session_activity.py +++ b/services/hermes/scripts/patch_web_session_activity.py @@ -52,7 +52,11 @@ HELPER_REPLACEMENT = '''def _run_activity_messages(session_id: str) -> List[Dict @app.get("/api/sessions/{session_id}/messages") -async def get_session_messages(session_id: str, profile: Optional[str] = None): +async def get_session_messages( + session_id: str, + profile: Optional[str] = None, + limit: Optional[int] = None, +): ''' MESSAGES_BEFORE = ''' messages = db.get_messages(sid) @@ -64,7 +68,126 @@ MESSAGES_AFTER = ''' messages = [ *_run_activity_messages(sid), ] messages.sort(key=lambda item: float(item.get("timestamp") or 0.0)) - return {"session_id": sid, "messages": messages} + total_messages = len(messages) + if limit is not None: + messages = messages[-max(1, min(int(limit), 10_000)) :] + return { + "session_id": sid, + "messages": messages, + "total_messages": total_messages, + } +''' + + +LATEST_ROWS_BEFORE = ''' "SELECT id, parent_session_id, started_at FROM sessions" + ).fetchall() + for row in raw_rows: + rows.append({ + "id": row_get(row, "id", 0), + "parent_session_id": row_get(row, "parent_session_id", 1), + "started_at": row_get(row, "started_at", 2), + }) +''' + +LATEST_ROWS_AFTER = ''' "SELECT id, parent_session_id, started_at, ended_at FROM sessions" + ).fetchall() + for row in raw_rows: + rows.append({ + "id": row_get(row, "id", 0), + "parent_session_id": row_get(row, "parent_session_id", 1), + "started_at": row_get(row, "started_at", 2), + "ended_at": row_get(row, "ended_at", 3), + }) +''' + +LATEST_SELECTION_BEFORE = ''' children = {} + for row in rows: + rid = row.get("id") + parent = row.get("parent_session_id") + if rid and parent: + children.setdefault(parent, []).append(row) + + def started(row): + try: + return float(row.get("started_at") or 0) + except Exception: + return 0.0 + + current = sid + path = [sid] + seen = {sid} + + while children.get(current): + candidates = [r for r in children[current] if r.get("id") not in seen] + if not candidates: + break + candidates.sort(key=started, reverse=True) + current = candidates[0]["id"] + path.append(current) + seen.add(current) + + return current, path +''' + +LATEST_SELECTION_AFTER = ''' children = {} + rows_by_id = {} + for row in rows: + rid = row.get("id") + parent = row.get("parent_session_id") + if rid: + rows_by_id[rid] = row + if rid and parent: + children.setdefault(parent, []).append(row) + + def started(row): + try: + return float(row.get("started_at") or 0) + except Exception: + return 0.0 + + # Old dashboard versions rewrote the URL to a child without retaining the + # root. Recover the oldest available ancestor so those existing live links + # can also follow a resumed parent or a newer delegated sibling. + anchor = sid + ancestor_seen = {anchor} + while True: + parent = (rows_by_id.get(anchor) or {}).get("parent_session_id") + if not parent or parent in ancestor_seen: + break + anchor = parent + ancestor_seen.add(anchor) + sid = anchor + + root = db.get_session(sid) or {"id": sid} + descendants = [(root, [sid])] + stack = [(sid, [sid])] + seen = {sid} + while stack: + parent, parent_path = stack.pop() + candidates = sorted(children.get(parent, []), key=started, reverse=True) + for row in candidates: + child = row.get("id") + if not child or child in seen: + continue + seen.add(child) + child_path = [*parent_path, child] + descendants.append((row, child_path)) + stack.append((child, child_path)) + + # A durable parent can resume after a delegated reviewer exits. Prefer the + # newest still-open member of the lineage instead of stranding the browser + # on the most recently created (but already finished) child. + active = [item for item in descendants if item[0].get("ended_at") is None] + if active: + row, path = max(active, key=lambda item: (started(item[0]), len(item[1]))) + return row.get("id") or sid, path + + leaves = [ + item for item in descendants + if not any(child.get("id") in seen for child in children.get(item[0].get("id"), [])) + ] + row, path = max(leaves or descendants, key=lambda item: started(item[0])) + return row.get("id") or sid, path ''' @@ -75,7 +198,13 @@ def patch(source: Path, destination: Path) -> None: raise RuntimeError("Hermes dashboard activity helper context changed") if MESSAGES_BEFORE not in content: raise RuntimeError("Hermes dashboard messages context changed") + if LATEST_ROWS_BEFORE not in content: + raise RuntimeError("Hermes dashboard lineage row context changed") + if LATEST_SELECTION_BEFORE not in content: + raise RuntimeError("Hermes dashboard lineage selection context changed") content = content.replace(HELPER_MARKER, HELPER_REPLACEMENT, 1) + content = content.replace(LATEST_ROWS_BEFORE, LATEST_ROWS_AFTER, 1) + content = content.replace(LATEST_SELECTION_BEFORE, LATEST_SELECTION_AFTER, 1) destination.parent.mkdir(parents=True, exist_ok=True) destination.write_text( content.replace(MESSAGES_BEFORE, MESSAGES_AFTER, 1), diff --git a/testing/tests/test_hermes_chat_quality.py b/testing/tests/test_hermes_chat_quality.py index e830d302..6b693cff 100644 --- a/testing/tests/test_hermes_chat_quality.py +++ b/testing/tests/test_hermes_chat_quality.py @@ -80,6 +80,9 @@ def test_agent_config_keeps_delegated_reviewers_from_owning_task_lifecycle(): assert "Only the foreground durable worker owns its Kanban task lifecycle" in instructions assert "must never complete, block, unblock, reclaim" in instructions assert "task's final structured result itself" in instructions + assert "Atlas organization has private visibility" in instructions + assert "private or Gitea-internal, never public" in instructions + assert "already supplied through `GIT_ASKPASS`" in instructions def test_sandbox_shares_only_the_tenant_workspace_without_credentials(): @@ -174,6 +177,9 @@ def test_gateway_image_honors_ui_model_and_caps_reasoning(): assert "SessionActivityPanel" in dockerfile assert "include_children=${includeChildren}" in dockerfile assert "_dashboard_session_is_active" in dockerfile + assert 'searchParams.get("lineage_root") || resumeParam' in dockerfile + assert "followActiveLineage" in dockerfile + assert 'next.delete("lineage_root")' in dockerfile activity = ( ROOT / "dockerfiles" / "hermes-session-activity-panel.tsx" ).read_text() @@ -184,6 +190,15 @@ def test_gateway_image_honors_ui_model_and_caps_reasoning(): assert "aria-busy={running && messages.length === 0}" in activity assert 'detail.source !== "api_server"' not in activity assert "Poll-backed transcript; terminal rendering is not required." in activity + assert "DEFAULT_VISIBLE_MESSAGES = 250" in activity + assert "current + DEFAULT_VISIBLE_MESSAGES" in activity + assert 'Show up to{" "}' in activity + assert "Math.min(" in activity + assert "visibleMessages.map" in activity + assert "response.total_messages" in activity + assert "visibleLimit)," in activity + assert "function displayText(value: unknown)" in activity + assert "open && (" in activity assert "Open Telegram setup" in dockerfile @@ -1071,6 +1086,7 @@ def test_api_session_patch_accepts_parent_lineage(tmp_path: Path): + "tool start body\n" + module.EVENT_CALLBACK_END_BEFORE + module.EVENT_CALLBACK_CALL_BEFORE + + module.RUN_SWEEP_BEFORE + "suffix\n", encoding="utf-8", ) @@ -1099,6 +1115,14 @@ def test_api_session_patch_accepts_parent_lineage(tmp_path: Path): assert 'self._record_run_activity(session_id, "run.started")' in patched assert 'detail = tool_name if event_type in {' in patched assert 'if event_type == "subagent.tool"' in patched + assert "_RUN_ACTIVITY_HEARTBEAT_SECONDS = 15.0" in patched + assert 'heartbeats.get(session_id, 0.0)' in patched + assert '"subagent.thinking",' in patched + assert "Stream retention and run lifetime are separate" in patched + assert 'terminal_status in {"completed", "failed", "cancelled"}' in patched + assert patched.index("terminal_status = self._run_statuses") < patched.index( + "self._active_run_tasks.pop(run_id, None)" + ) def test_web_session_activity_patch_projects_bounded_events(tmp_path: Path): @@ -1112,6 +1136,8 @@ def test_web_session_activity_patch_projects_bounded_events(tmp_path: Path): destination = tmp_path / "patched.py" source.write_text( "prefix\n" + + module.LATEST_ROWS_BEFORE + + module.LATEST_SELECTION_BEFORE + module.HELPER_MARKER + " db = object()\n" + module.MESSAGES_BEFORE @@ -1128,6 +1154,114 @@ def test_web_session_activity_patch_projects_bounded_events(tmp_path: Path): assert "entries[-1_000:]" in patched assert "*_run_activity_messages(sid)" in patched assert "messages.sort(" in patched + assert "limit: Optional[int] = None" in patched + assert "total_messages = len(messages)" in patched + assert "min(int(limit), 10_000)" in patched + assert '"total_messages": total_messages' in patched + assert "SELECT id, parent_session_id, started_at, ended_at" in patched + assert "newest still-open member of the lineage" in patched + assert "oldest available ancestor" in patched + assert 'item[0].get("ended_at") is None' in patched + + +def test_web_session_lineage_returns_to_resumed_parent(): + """An ended reviewer must not strand the live view away from its parent.""" + module_path = HERMES / "scripts" / "patch_web_session_activity.py" + spec = importlib.util.spec_from_file_location("patch_web_lineage", module_path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + namespace: dict[str, object] = {} + exec( + "def select_active(sid, db, rows):\n" + module.LATEST_SELECTION_AFTER, + namespace, + ) + + class FakeDB: + root = { + "id": "root", + "started_at": 1.0, + "ended_at": None, + } + + def get_session(self, _session_id): + return self.root + + rows = [ + { + "id": "root", + "parent_session_id": None, + "started_at": 1.0, + "ended_at": None, + }, + { + "id": "review-1", + "parent_session_id": "root", + "started_at": 2.0, + "ended_at": 3.0, + }, + { + "id": "review-2", + "parent_session_id": "root", + "started_at": 4.0, + "ended_at": None, + }, + ] + select_active = namespace["select_active"] + + assert select_active("root", FakeDB(), rows) == ( + "review-2", + ["root", "review-2"], + ) + assert select_active("review-1", FakeDB(), rows) == ( + "review-2", + ["root", "review-2"], + ) + rows[1]["ended_at"] = 5.0 + rows[2]["ended_at"] = 5.0 + assert select_active("root", FakeDB(), rows) == ("root", ["root"]) + + +def test_api_activity_patch_coalesces_streaming_heartbeats( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """Token callbacks stay bounded while every tool transition is retained.""" + module_path = HERMES / "scripts" / "patch_api_server_sessions.py" + spec = importlib.util.spec_from_file_location("patch_api_activity", module_path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + activity_body = module.EVENT_CALLBACK_SIGNATURE_AFTER.split( + " def _make_run_event_callback(", 1 + )[0] + namespace: dict[str, object] = {} + exec( + "import hashlib, json, logging, os, time\n" + "from pathlib import Path\n" + "logger = logging.getLogger(__name__)\n" + "def redact_sensitive_text(value): return value\n" + "class ActivityRecorder:\n" + + activity_body, + namespace, + ) + recorder = namespace["ActivityRecorder"]() + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + recorder._record_run_activity("session", "_thinking") + recorder._record_run_activity("session", "reasoning.available") + recorder._record_run_activity("session", "subagent.thinking") + recorder._record_run_activity("session", "tool.started", tool_name="terminal") + recorder._record_run_activity("session", "tool.completed", tool_name="terminal") + + journals = list((tmp_path / "run-activity").glob("*.jsonl")) + assert len(journals) == 1 + entries = [json.loads(line) for line in journals[0].read_text().splitlines()] + assert [entry["activity_event"] for entry in entries] == [ + "_thinking", + "tool.started", + "tool.completed", + ] def test_legacy_api_sessions_are_nested_idempotently(tmp_path: Path): diff --git a/testing/tests/test_hermes_cli_lanes.py b/testing/tests/test_hermes_cli_lanes.py index 30c74d3e..8d2318b1 100644 --- a/testing/tests/test_hermes_cli_lanes.py +++ b/testing/tests/test_hermes_cli_lanes.py @@ -1204,6 +1204,15 @@ def test_owner_agent_installs_the_pinned_operator_toolchain(): ) assert "/bin/sh /opt/coordinator/install_agent_tools.sh" in installer["command"][2] assert any(mount["name"] == "coordinator" for mount in installer["volumeMounts"]) + init_config = next( + item + for item in deployment["spec"]["template"]["spec"]["initContainers"] + if item["name"] == "init-config" + ) + init_command = init_config["command"][2] + assert "# Hermes managed operator PATH." in init_command + assert "/opt/coordinator:/opt/data/tools/bin:/opt/hermes/.venv/bin" in init_command + assert 'chmod 0644 "${profile_file}"' in init_command def test_owner_agent_uses_only_the_canonical_hostname():