hermes: restore terminal rendering and worker lineage
Some checks failed
Tests / Declarative: Post Actions testing.tests.test_hermes_chat_quality.test_compact_image_edit_resolves_latest_tenant_artifact failed

This commit is contained in:
jenkins 2026-08-13 02:49:41 -03:00
parent 1e5ad40cfb
commit 009336fb64
5 changed files with 410 additions and 8 deletions

View File

@ -685,6 +685,344 @@ for before, after, label in replacements:
path.write_text(source) path.write_text(source)
PY PY
# Keep the browser terminal reliable across GPU context loss, make durable
# worker lineage visible, and expose the family Telegram-linking entry point.
RUN node <<'NODE'
const fs = require("node:fs");
function replaceOnce(source, before, after, label) {
const count = source.split(before).length - 1;
if (count !== 1) {
throw new Error(`${label} patch context changed: expected 1, found ${count}`);
}
return source.replace(before, after);
}
// A WebGL context can be discarded while a browser tab sleeps or loses its
// GPU process. xterm's canvas renderer is fast enough for this single PTY and
// can be repainted deterministically from the server-side replay buffer.
{
const path = "/opt/hermes/web/src/pages/ChatPage.tsx";
let source = fs.readFileSync(path, "utf8");
source = replaceOnce(
source,
'import { WebglAddon } from "@xterm/addon-webgl";\n',
"",
"xterm WebGL import",
);
const webglStart = source.indexOf(" // WebGL draws from");
const webglEnd = source.indexOf(
"\n\n // Initial fit + resize observer",
webglStart,
);
if (webglStart < 0 || webglEnd < 0) {
throw new Error("xterm WebGL setup patch context changed");
}
source =
source.slice(0, webglStart) +
` // Use xterm's canvas renderer. It survives browser sleep and GPU-process
// resets, while the server-side PTY replay restores any missed output.
` +
source.slice(webglEnd + 2);
source = replaceOnce(
source,
" term.open(host);\n",
` term.open(host);
// Coalesce terminal repaints so buffered replay and visibility changes
// cannot leave a valid xterm buffer hidden behind a stale renderer frame.
let paintRaf = 0;
const scheduleTerminalPaint = () => {
if (paintRaf) return;
paintRaf = requestAnimationFrame(() => {
paintRaf = 0;
if (!host.isConnected || term.rows <= 0) return;
try {
term.refresh(0, term.rows - 1);
} catch {
/* terminal disposed during a navigation */
}
});
};
const refreshVisibleTerminal = () => {
if (document.hidden) return;
syncMetricsRef.current?.();
scheduleTerminalPaint();
};
document.addEventListener("visibilitychange", refreshVisibleTerminal);
`,
"xterm repaint scheduler",
);
source = replaceOnce(
source,
' ws.send(`\\x1b[RESIZE:${term.cols};${term.rows}]`);\n',
' ws.send(`\\x1b[RESIZE:${term.cols};${term.rows}]`);\n' +
" scheduleTerminalPaint();\n",
"xterm open repaint",
);
source = replaceOnce(
source,
` ws.onmessage = (ev) => {
if (typeof ev.data === "string") {
term.write(ev.data);
} else {
term.write(new Uint8Array(ev.data as ArrayBuffer));
}
};
`,
` ws.onmessage = (ev) => {
if (typeof ev.data === "string") {
term.write(ev.data, scheduleTerminalPaint);
} else {
term.write(new Uint8Array(ev.data as ArrayBuffer), scheduleTerminalPaint);
}
};
`,
"xterm replay repaint",
);
source = replaceOnce(
source,
" ro.disconnect();\n",
` ro.disconnect();
document.removeEventListener("visibilitychange", refreshVisibleTerminal);
if (paintRaf) cancelAnimationFrame(paintRaf);
`,
"xterm repaint cleanup",
);
source = source.replace(
"@xterm/xterm Terminal (WebGL renderer, Unicode 11 widths)",
"@xterm/xterm Terminal (repaintable canvas, Unicode 11 widths)",
);
fs.writeFileSync(path, source);
}
// The API already stores parent_session_id. Present it as a collapsible tree
// instead of making durable Codex/Claude/API workers look like root chats.
{
const path = "/opt/hermes/web/src/components/ChatSessionList.tsx";
let source = fs.readFileSync(path, "utf8");
source = replaceOnce(
source,
'import { AlertCircle, MessageSquarePlus, RefreshCw } from "lucide-react";',
'import { AlertCircle, ChevronDown, ChevronRight, GitBranch, MessageSquarePlus, RefreshCw } from "lucide-react";',
"session-tree icons",
);
source = replaceOnce(
source,
"const SESSION_LIMIT = 30;",
"const SESSION_LIMIT = 100;",
"session-tree list limit",
);
source = replaceOnce(
source,
" const [reloadNonce, setReloadNonce] = useState(0);\n",
` const [reloadNonce, setReloadNonce] = useState(0);
const [expandedParents, setExpandedParents] = useState<Set<string>>(
() => new Set(),
);
`,
"session-tree expansion state",
);
source = replaceOnce(
source,
" const content = useMemo(() => {\n",
` const sessionTree = useMemo(() => {
const listed = sessions ?? [];
const sessionIds = new Set(listed.map((session) => session.id));
const childrenByParent = new Map<string, SessionInfo[]>();
const roots: SessionInfo[] = [];
for (const session of listed) {
const parent = session.parent_session_id;
if (parent && sessionIds.has(parent)) {
const children = childrenByParent.get(parent) ?? [];
children.push(session);
childrenByParent.set(parent, children);
} else {
roots.push(session);
}
}
return { childrenByParent, roots };
}, [sessions]);
useEffect(() => {
if (!activeSessionId) return;
const active = sessions?.find((session) => session.id === activeSessionId);
if (!active?.parent_session_id) return;
setExpandedParents((previous) => {
if (previous.has(active.parent_session_id!)) return previous;
const next = new Set(previous);
next.add(active.parent_session_id!);
return next;
});
}, [activeSessionId, sessions]);
const toggleParent = useCallback((id: string) => {
setExpandedParents((previous) => {
const next = new Set(previous);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const content = useMemo(() => {
`,
"session-tree derivation",
);
const listStart = source.indexOf(" {sessions.map((s) => {");
const listEndMarker = " })}\n </div>";
const listEnd = source.indexOf(listEndMarker, listStart);
if (listStart < 0 || listEnd < 0) {
throw new Error("session-tree list rendering patch context changed");
}
const listReplacement = String.raw` {sessionTree.roots.flatMap((root) => {
const children = sessionTree.childrenByParent.get(root.id) ?? [];
const rows: Array<{ session: SessionInfo; nested: boolean }> = [
{ session: root, nested: false },
];
if (expandedParents.has(root.id)) {
rows.push(...children.map((session) => ({ session, nested: true })));
}
return rows.map(({ session: s, nested }) => {
const isActive = s.id === activeSessionId;
const childCount = sessionTree.childrenByParent.get(s.id)?.length ?? 0;
const expanded = expandedParents.has(s.id);
return (
<div
key={s.id}
className={cn(
"flex min-w-0 items-stretch gap-0.5",
nested && "ml-4 border-l border-current/15 pl-1",
)}
>
{childCount > 0 ? (
<button
type="button"
aria-label={expanded ? "Collapse worker sessions" : "Expand worker sessions"}
aria-expanded={expanded}
title={childCount + " worker session" + (childCount === 1 ? "" : "s")}
onClick={() => toggleParent(s.id)}
className="flex w-5 shrink-0 items-center justify-center text-text-tertiary hover:text-foreground"
>
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
) : nested ? (
<span className="flex w-5 shrink-0 items-center justify-center text-text-tertiary" aria-hidden>
<GitBranch className="h-3 w-3" />
</span>
) : (
<span className="w-5 shrink-0" aria-hidden />
)}
<ListItem
onClick={() => pick(s.id)}
aria-current={isActive ? "true" : undefined}
className={cn(
"min-w-0 flex-1 flex-col items-start gap-0.5 rounded px-2 py-1.5",
"normal-case tracking-normal",
isActive
? "bg-primary/10 text-foreground border-l-2 border-primary"
: "text-text-secondary hover:bg-midground/5 hover:text-foreground",
)}
>
<span className="flex w-full items-center gap-1 truncate text-sm font-medium">
<span className="truncate">{rowLabel(s, t.sessions.untitledSession)}</span>
{childCount > 0 && (
<span className="shrink-0 text-[0.625rem] text-text-tertiary">
{childCount} workers
</span>
)}
</span>
<span className="flex w-full items-center gap-1.5 text-[0.6875rem] text-text-tertiary">
<span>{timeAgo(s.last_active)}</span>
{s.message_count > 0 && (
<>
<span aria-hidden>·</span>
<span>{s.message_count} msgs</span>
</>
)}
{s.source && s.source !== "cli" && (
<>
<span aria-hidden>·</span>
<span className="truncate">{s.source}</span>
</>
)}
</span>
</ListItem>
</div>
);
});
})}`;
source =
source.slice(0, listStart) +
listReplacement +
source.slice(listEnd + " })}".length);
source = replaceOnce(
source,
" }, [activeSessionId, error, loading, pick, reload, sessions, t]);",
" }, [activeSessionId, error, expandedParents, loading, pick, reload, sessionTree, sessions, t, toggleParent]);",
"session-tree memo dependencies",
);
fs.writeFileSync(path, source);
}
// Telegram has one operator-managed bot, but every Keycloak user links their
// own account. Put that workflow in normal navigation instead of a floating
// button or a URL users have to remember.
{
const path = "/opt/hermes/web/src/App.tsx";
let source = fs.readFileSync(path, "utf8");
source = replaceOnce(
source,
"function RootRedirect() {\n",
`function TelegramSetupPage() {
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-5 p-6 lg:p-10">
<div>
<h2 className="text-2xl font-bold">Telegram</h2>
<p className="mt-2 text-sm text-text-secondary">
Atlas uses one operator-managed Hermes bot. Each Keycloak user links
their own Telegram account once; conversations remain attached to
that user's isolated family-chat workspace.
</p>
</div>
<div className="rounded border border-current/20 bg-midground/5 p-5">
<h3 className="text-sm font-bold tracking-wider">Link this account</h3>
<p className="mt-2 text-sm text-text-secondary">
Open the family-chat linking page, create a one-time code, then send
it to the shared Hermes bot from your Telegram account.
</p>
<a
href="https://chat.hermes.bstein.dev/telegram"
className="mt-4 inline-flex rounded border border-current/30 px-4 py-2 text-sm font-medium text-midground hover:bg-midground/10"
>
Open Telegram setup
</a>
</div>
</div>
);
}
function RootRedirect() {
`,
"Telegram setup page",
);
source = replaceOnce(
source,
' "/channels": ChannelsPage,\n',
' "/channels": ChannelsPage,\n "/telegram": TelegramSetupPage,\n',
"Telegram setup route",
);
source = replaceOnce(
source,
' { path: "/channels", label: "Channels", icon: Radio },\n',
' { path: "/channels", label: "Channels", icon: Radio },\n { path: "/telegram", label: "Telegram", icon: MessageSquare },\n',
"Telegram setup navigation",
);
fs.writeFileSync(path, source);
}
NODE
COPY dockerfiles/hermes-session-migrate.py /opt/hermes/bin/hermes-session-migrate COPY dockerfiles/hermes-session-migrate.py /opt/hermes/bin/hermes-session-migrate
RUN cd /opt/hermes/web \ RUN cd /opt/hermes/web \
@ -711,6 +1049,11 @@ RUN cd /opt/hermes/web \
/opt/hermes/hermes_cli/web_server.py \ /opt/hermes/hermes_cli/web_server.py \
&& grep -Fq 'Claude Code subscription (Switchyard)' \ && grep -Fq 'Claude Code subscription (Switchyard)' \
/opt/hermes/hermes_cli/web_server.py \ /opt/hermes/hermes_cli/web_server.py \
&& ! grep -Fq 'WebglAddon' src/pages/ChatPage.tsx \
&& grep -Fq 'scheduleTerminalPaint' src/pages/ChatPage.tsx \
&& grep -Fq 'sessionTree.childrenByParent' \
src/components/ChatSessionList.tsx \
&& grep -Fq 'Open Telegram setup' src/App.tsx \
&& grep -Fq '"pre_turn_route"' /opt/hermes/hermes_cli/plugins.py \ && grep -Fq '"pre_turn_route"' /opt/hermes/hermes_cli/plugins.py \
&& grep -Fq '"pre_internal_route"' /opt/hermes/hermes_cli/plugins.py \ && grep -Fq '"pre_internal_route"' /opt/hermes/hermes_cli/plugins.py \
&& grep -Fq '"pre_subagent_route"' /opt/hermes/hermes_cli/plugins.py \ && grep -Fq '"pre_subagent_route"' /opt/hermes/hermes_cli/plugins.py \

View File

@ -25,7 +25,7 @@ spec:
ai.bstein.dev/execution: Hermes Kanban with durable direct Codex and Claude Code CLI workers ai.bstein.dev/execution: Hermes Kanban with durable direct Codex and Claude Code CLI workers
ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback
ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available
ai.bstein.dev/config-rev: "20260812-provider-history-lineage-cleanup" ai.bstein.dev/config-rev: "20260813-terminal-lineage-oauth"
vault.hashicorp.com/agent-inject: "true" vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: hermes-agent vault.hashicorp.com/role: hermes-agent
vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens
@ -521,6 +521,7 @@ spec:
- --scope=openid profile email - --scope=openid profile email
- --email-domain=* - --email-domain=*
- --authenticated-emails-file=/etc/oauth2-proxy/allowed-emails - --authenticated-emails-file=/etc/oauth2-proxy/allowed-emails
- --custom-templates-dir=/etc/oauth2-proxy/templates
- --set-xauthrequest=true - --set-xauthrequest=true
- --pass-user-headers=true - --pass-user-headers=true
- --pass-basic-auth=false - --pass-basic-auth=false
@ -567,6 +568,7 @@ spec:
limits: {cpu: 250m, memory: 256Mi} limits: {cpu: 250m, memory: 256Mi}
volumeMounts: volumeMounts:
- {name: allowlist, mountPath: /etc/oauth2-proxy, readOnly: true} - {name: allowlist, mountPath: /etc/oauth2-proxy, readOnly: true}
- {name: oauth-templates, mountPath: /etc/oauth2-proxy/templates, readOnly: true}
- {name: oauth-tmp, mountPath: /tmp} - {name: oauth-tmp, mountPath: /tmp}
- name: terminal - name: terminal
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107 image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
@ -942,6 +944,9 @@ spec:
- name: allowlist - name: allowlist
configMap: configMap:
name: hermes-owner-allowlist name: hermes-owner-allowlist
- name: oauth-templates
configMap:
name: hermes-chat-oauth-templates
- name: ttyd-index - name: ttyd-index
emptyDir: emptyDir:
sizeLimit: 2Mi sizeLimit: 2Mi

View File

@ -4,7 +4,7 @@ kind: Kustomization
namespace: hermes namespace: hermes
images: images:
- name: registry.bstein.dev/bstein/hermes-agent - name: registry.bstein.dev/bstein/hermes-agent
digest: sha256:ce106ccb408684677176cc9477b785fbbe5b6171427c4931b86dac7f51d5c2b3 digest: sha256:3154e1df5f6941583f4a369fcc9581bbf9cb9d502676e91b557e90cc576923e1
resources: resources:
- namespace.yaml - namespace.yaml
- vault-serviceaccount.yaml - vault-serviceaccount.yaml

View File

@ -5,7 +5,36 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{.StatusCode}} {{.Title}}</title> <title>{{.StatusCode}} {{.Title}}</title>
{{if or (eq .StatusCode 500) (eq .Message "Login Failed: Unable to find a valid CSRF token. Please try again.")}}<meta http-equiv="refresh" content="0;url={{.ProxyPrefix}}/sign_in?rd=/">{{end}} {{if or (eq .StatusCode 500) (eq .Message "Login Failed: Unable to find a valid CSRF token. Please try again.")}}
<script>
(() => {
const retryKey = "hermes.oauth.callback-recovery";
const retryWindowMs = 60_000;
let shouldRetry = false;
try {
const previous = Number(window.sessionStorage.getItem(retryKey) || "0");
const now = Date.now();
shouldRetry = !previous || now - previous > retryWindowMs;
if (shouldRetry) window.sessionStorage.setItem(retryKey, String(now));
} catch {
// Storage-disabled browsers keep the manual recovery link below.
}
if (shouldRetry) {
window.setTimeout(() => {
window.location.replace("{{.ProxyPrefix}}/start?rd=/");
}, 350);
} else {
window.addEventListener("DOMContentLoaded", () => {
const detail = document.getElementById("recovery-detail");
if (detail) {
detail.textContent =
"Automatic recovery paused to avoid a sign-in loop. Continue once to start a fresh login.";
}
});
}
})();
</script>
{{end}}
<style> <style>
body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f5f5f5;color:#333;font:16px/1.5 system-ui,sans-serif} body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f5f5f5;color:#333;font:16px/1.5 system-ui,sans-serif}
main{width:min(560px,calc(100% - 40px));box-sizing:border-box;padding:36px;border:1px solid #ddd;border-radius:14px;background:#fff;text-align:center;box-shadow:0 12px 45px #0002} main{width:min(560px,calc(100% - 40px));box-sizing:border-box;padding:36px;border:1px solid #ddd;border-radius:14px;background:#fff;text-align:center;box-shadow:0 12px 45px #0002}
@ -16,12 +45,12 @@
<main> <main>
{{if or (eq .StatusCode 500) (eq .Message "Login Failed: Unable to find a valid CSRF token. Please try again.")}} {{if or (eq .StatusCode 500) (eq .Message "Login Failed: Unable to find a valid CSRF token. Please try again.")}}
<h1>Signing you back in…</h1> <h1>Signing you back in…</h1>
<p class="detail">The previous one-time login callback expired or was already used. Hermes is starting a fresh sign-in automatically.</p> <p id="recovery-detail" class="detail">The previous one-time login callback expired or was already used. Hermes is starting one fresh sign-in automatically.</p>
<a href="{{.ProxyPrefix}}/sign_in?rd=/">Continue now</a> <a href="{{.ProxyPrefix}}/start?rd=/">Continue now</a>
{{else}} {{else}}
<h1>{{.StatusCode}} {{.Title}}</h1> <h1>{{.StatusCode}} {{.Title}}</h1>
{{if .Message}}<p class="detail">{{.Message}}</p>{{end}} {{if .Message}}<p class="detail">{{.Message}}</p>{{end}}
<a href="{{.ProxyPrefix}}/sign_in?rd=/">Sign in again</a> <a href="{{.ProxyPrefix}}/start?rd=/">Sign in again</a>
{{end}} {{end}}
</main> </main>
</body> </body>

View File

@ -153,6 +153,9 @@ def test_gateway_image_honors_ui_model_and_caps_reasoning():
assert '"source": "managed_claude_code_subscription"' in dockerfile assert '"source": "managed_claude_code_subscription"' in dockerfile
assert '"name": "Claude Code subscription (Switchyard)"' in dockerfile assert '"name": "Claude Code subscription (Switchyard)"' in dockerfile
assert "Anthropic API / direct OAuth (not used by Switchyard)" in dockerfile assert "Anthropic API / direct OAuth (not used by Switchyard)" in dockerfile
assert "scheduleTerminalPaint" in dockerfile
assert "sessionTree.childrenByParent" in dockerfile
assert "Open Telegram setup" in dockerfile
def test_chat_oauth_allows_stale_service_worker_retirement(): def test_chat_oauth_allows_stale_service_worker_retirement():
@ -169,10 +172,12 @@ def test_chat_oauth_allows_stale_service_worker_retirement():
assert "--custom-templates-dir=/etc/oauth2-proxy/templates" in args assert "--custom-templates-dir=/etc/oauth2-proxy/templates" in args
template = (HERMES / "oauth2-proxy-templates" / "error.html").read_text() template = (HERMES / "oauth2-proxy-templates" / "error.html").read_text()
assert 'http-equiv="refresh"' in template assert 'http-equiv="refresh"' not in template
assert "Unable to find a valid CSRF token" in template assert "Unable to find a valid CSRF token" in template
assert "expired or was already used" in template assert "expired or was already used" in template
assert "/sign_in?rd=/" in template assert "sessionStorage" in template
assert "Automatic recovery paused" in template
assert "/start?rd=/" in template
container = deployment["spec"]["template"]["spec"]["containers"][0] container = deployment["spec"]["template"]["spec"]["containers"][0]
assert "v7.15.3@sha256:10a1165743a192e" in container["image"] assert "v7.15.3@sha256:10a1165743a192e" in container["image"]
assert "--cookie-csrf-per-request=true" in args assert "--cookie-csrf-per-request=true" in args
@ -188,6 +193,26 @@ def test_chat_oauth_allows_stale_service_worker_retirement():
for arg in args for arg in args
) )
agent = _documents(HERMES / "agent-deployment.yaml")[0]
agent_pod = agent["spec"]["template"]["spec"]
agent_oauth = next(
container for container in agent_pod["containers"]
if container["name"] == "oauth2-proxy"
)
assert "--custom-templates-dir=/etc/oauth2-proxy/templates" in agent_oauth["args"]
assert {
"name": "oauth-templates",
"mountPath": "/etc/oauth2-proxy/templates",
"readOnly": True,
} in agent_oauth["volumeMounts"]
agent_template_volume = next(
volume for volume in agent_pod["volumes"]
if volume["name"] == "oauth-templates"
)
assert agent_template_volume["configMap"]["name"] == (
"hermes-chat-oauth-templates"
)
def test_webui_recovers_auth_and_labels_session_scoped_controls(): def test_webui_recovers_auth_and_labels_session_scoped_controls():
dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-webui").read_text() dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-webui").read_text()