hermes: keep durable worker activity visible
This commit is contained in:
parent
4a750c9717
commit
9bc47f9ce3
@ -856,6 +856,83 @@ function replaceOnce(source, before, after, label) {
|
|||||||
"",
|
"",
|
||||||
"xterm WebGL import",
|
"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 webglStart = source.indexOf(" // WebGL draws from");
|
||||||
const webglEnd = source.indexOf(
|
const webglEnd = source.indexOf(
|
||||||
"\n\n // Initial fit + resize observer",
|
"\n\n // Initial fit + resize observer",
|
||||||
@ -993,6 +1070,40 @@ function replaceOnce(source, before, after, label) {
|
|||||||
),`,
|
),`,
|
||||||
"session child query option",
|
"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<SessionMessagesResponse>(
|
||||||
|
appendProfileParam(\`/api/sessions/\${encodeURIComponent(id)}/messages\`, profile),
|
||||||
|
),`,
|
||||||
|
` getSessionMessages: (
|
||||||
|
id: string,
|
||||||
|
profile = getManagementProfile(),
|
||||||
|
limit?: number,
|
||||||
|
) =>
|
||||||
|
fetchJSON<SessionMessagesResponse>(
|
||||||
|
appendProfileParam(
|
||||||
|
\`/api/sessions/\${encodeURIComponent(id)}/messages\${
|
||||||
|
limit ? \`?limit=\${Math.max(1, Math.floor(limit))}\` : ""
|
||||||
|
}\`,
|
||||||
|
profile,
|
||||||
|
),
|
||||||
|
),`,
|
||||||
|
"bounded session message query",
|
||||||
|
);
|
||||||
fs.writeFileSync(path, source);
|
fs.writeFileSync(path, source);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1019,6 +1130,18 @@ function replaceOnce(source, before, after, label) {
|
|||||||
'.getSessions(SESSION_LIMIT, 0, scopeKey, "recent", true)',
|
'.getSessions(SESSION_LIMIT, 0, scopeKey, "recent", true)',
|
||||||
"session-tree child query",
|
"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 = replaceOnce(
|
||||||
source,
|
source,
|
||||||
" const [reloadNonce, setReloadNonce] = useState(0);\n",
|
" 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 'api.getSessions(1, 0' src/components/ChatSidebar.tsx \
|
||||||
&& grep -Fq 'if (unmounting) return;' src/pages/ChatPage.tsx \
|
&& grep -Fq 'if (unmounting) return;' src/pages/ChatPage.tsx \
|
||||||
&& grep -Fq 'resume:${resumeParam}' 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 'eventsRetryAttempt.current' src/components/ChatSidebar.tsx \
|
||||||
&& grep -Fq 'reconnecting…' src/components/ChatSidebar.tsx \
|
&& grep -Fq 'reconnecting…' src/components/ChatSidebar.tsx \
|
||||||
&& grep -Fq 'redirectToProxyLogin' src/lib/api.ts \
|
&& grep -Fq 'redirectToProxyLogin' src/lib/api.ts \
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import {
|
|||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
|
||||||
const POLL_MS = 2_000;
|
const POLL_MS = 2_000;
|
||||||
|
const DEFAULT_VISIBLE_MESSAGES = 250;
|
||||||
|
|
||||||
interface SessionActivityPanelProps {
|
interface SessionActivityPanelProps {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@ -29,7 +30,7 @@ function messageLabel(message: SessionMessage): string {
|
|||||||
}
|
}
|
||||||
if (message.tool_calls?.length) {
|
if (message.tool_calls?.length) {
|
||||||
const names = message.tool_calls
|
const names = message.tool_calls
|
||||||
.map((call) => call.function.name)
|
.map((call) => call?.function?.name)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
return names ? `Tool calls: ${names}` : "Tool calls";
|
return names ? `Tool calls: ${names}` : "Tool calls";
|
||||||
@ -37,6 +38,43 @@ function messageLabel(message: SessionMessage): string {
|
|||||||
return message.role === "assistant" ? "Hermes" : message.role;
|
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 (
|
||||||
|
<details
|
||||||
|
className="my-1 rounded border border-white/10 px-2 py-1"
|
||||||
|
open={open}
|
||||||
|
onToggle={(event) => setOpen(event.currentTarget.open)}
|
||||||
|
>
|
||||||
|
<summary className="cursor-pointer select-none text-white/75">{summary}</summary>
|
||||||
|
{open && (
|
||||||
|
<pre className="mt-2 max-h-96 overflow-auto whitespace-pre-wrap break-words text-[0.6875rem] leading-relaxed text-white/75">
|
||||||
|
{payload}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function latestTimestamp(messages: SessionMessage[]): number | null {
|
function latestTimestamp(messages: SessionMessage[]): number | null {
|
||||||
let latest: number | null = null;
|
let latest: number | null = null;
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
@ -53,8 +91,8 @@ function ActivityMessage({
|
|||||||
message: SessionMessage;
|
message: SessionMessage;
|
||||||
index: number;
|
index: number;
|
||||||
}) {
|
}) {
|
||||||
const toolCalls = message.tool_calls ?? [];
|
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
||||||
const content = message.content ?? "";
|
const content = displayText(message.content);
|
||||||
const isToolResult = message.role === "tool";
|
const isToolResult = message.role === "tool";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -71,26 +109,23 @@ function ActivityMessage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{toolCalls.map((call) => (
|
{toolCalls.map((call, callIndex) => {
|
||||||
<details key={call.id} className="my-1 rounded border border-white/10 px-2 py-1">
|
const name = call?.function?.name || "tool";
|
||||||
<summary className="cursor-pointer select-none text-white/75">
|
return (
|
||||||
{call.function.name || "tool"} arguments
|
<ExpandablePayload
|
||||||
</summary>
|
key={call?.id || `${index}-${callIndex}`}
|
||||||
<pre className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap break-words text-[0.6875rem] text-white/70">
|
payload={displayText(call?.function?.arguments)}
|
||||||
{call.function.arguments}
|
summary={`${name} arguments`}
|
||||||
</pre>
|
/>
|
||||||
</details>
|
);
|
||||||
))}
|
})}
|
||||||
|
|
||||||
{content && isToolResult ? (
|
{content && isToolResult ? (
|
||||||
<details className="mt-1" open={content.length < 1_500}>
|
<ExpandablePayload
|
||||||
<summary className="cursor-pointer select-none text-white/75">
|
payload={content}
|
||||||
{content.length.toLocaleString()} characters of output
|
summary={`${content.length.toLocaleString()} characters of output`}
|
||||||
</summary>
|
openByDefault={content.length < 1_500}
|
||||||
<pre className="mt-2 max-h-96 overflow-auto whitespace-pre-wrap break-words text-[0.6875rem] leading-relaxed text-white/75">
|
/>
|
||||||
{content}
|
|
||||||
</pre>
|
|
||||||
</details>
|
|
||||||
) : content ? (
|
) : content ? (
|
||||||
<pre className="whitespace-pre-wrap break-words font-sans leading-relaxed text-white/90">
|
<pre className="whitespace-pre-wrap break-words font-sans leading-relaxed text-white/90">
|
||||||
{content}
|
{content}
|
||||||
@ -114,7 +149,9 @@ export function SessionActivityPanel({
|
|||||||
}: SessionActivityPanelProps) {
|
}: SessionActivityPanelProps) {
|
||||||
const [detail, setDetail] = useState<SessionInfo | null>(null);
|
const [detail, setDetail] = useState<SessionInfo | null>(null);
|
||||||
const [messages, setMessages] = useState<SessionMessage[]>([]);
|
const [messages, setMessages] = useState<SessionMessage[]>([]);
|
||||||
|
const [totalMessages, setTotalMessages] = useState(0);
|
||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
const [visibleLimit, setVisibleLimit] = useState(DEFAULT_VISIBLE_MESSAGES);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [pollTime, setPollTime] = useState(Date.now());
|
const [pollTime, setPollTime] = useState(Date.now());
|
||||||
const requestRunning = useRef(false);
|
const requestRunning = useRef(false);
|
||||||
@ -127,10 +164,16 @@ export function SessionActivityPanel({
|
|||||||
try {
|
try {
|
||||||
const [nextDetail, response] = await Promise.all([
|
const [nextDetail, response] = await Promise.all([
|
||||||
api.getSessionDetail(sessionId, profile ?? ""),
|
api.getSessionDetail(sessionId, profile ?? ""),
|
||||||
api.getSessionMessages(sessionId, profile ?? ""),
|
api.getSessionMessages(sessionId, profile ?? "", visibleLimit),
|
||||||
]);
|
]);
|
||||||
setDetail(nextDetail);
|
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);
|
setError(null);
|
||||||
setPollTime(Date.now());
|
setPollTime(Date.now());
|
||||||
if (stickToBottom.current) {
|
if (stickToBottom.current) {
|
||||||
@ -144,6 +187,10 @@ export function SessionActivityPanel({
|
|||||||
} finally {
|
} finally {
|
||||||
requestRunning.current = false;
|
requestRunning.current = false;
|
||||||
}
|
}
|
||||||
|
}, [profile, sessionId, visibleLimit]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVisibleLimit(DEFAULT_VISIBLE_MESSAGES);
|
||||||
}, [profile, sessionId]);
|
}, [profile, sessionId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -160,6 +207,14 @@ export function SessionActivityPanel({
|
|||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
const latest = useMemo(() => latestTimestamp(messages), [messages]);
|
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 quietSeconds = latest === null ? null : Math.max(0, pollTime / 1000 - latest);
|
||||||
const running = detail?.ended_at == null;
|
const running = detail?.ended_at == null;
|
||||||
const status = error
|
const status = error
|
||||||
@ -199,7 +254,8 @@ export function SessionActivityPanel({
|
|||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-atomic="true"
|
aria-atomic="true"
|
||||||
>
|
>
|
||||||
{status} · {messages.length} recorded events
|
{status} · {messages.length.toLocaleString()} of{" "}
|
||||||
|
{totalMessages.toLocaleString()} recorded events loaded
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -245,6 +301,29 @@ export function SessionActivityPanel({
|
|||||||
node.scrollHeight - node.scrollTop - node.clientHeight < 80;
|
node.scrollHeight - node.scrollTop - node.clientHeight < 80;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{hiddenMessageCount > 0 && (
|
||||||
|
<div className="sticky top-0 z-10 flex justify-center pb-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
stickToBottom.current = false;
|
||||||
|
setVisibleLimit((current) => current + DEFAULT_VISIBLE_MESSAGES);
|
||||||
|
}}
|
||||||
|
prefix={<ChevronUp className="h-3.5 w-3.5" />}
|
||||||
|
aria-label={`Show up to ${Math.min(
|
||||||
|
DEFAULT_VISIBLE_MESSAGES,
|
||||||
|
hiddenMessageCount,
|
||||||
|
)} earlier recorded events`}
|
||||||
|
className="bg-black/95 text-white"
|
||||||
|
>
|
||||||
|
Show up to{" "}
|
||||||
|
{Math.min(
|
||||||
|
DEFAULT_VISIBLE_MESSAGES,
|
||||||
|
hiddenMessageCount,
|
||||||
|
).toLocaleString()}{" "}
|
||||||
|
earlier events
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
<div className="flex min-h-full flex-col items-center justify-center gap-3 text-center text-sm text-white/65">
|
<div className="flex min-h-full flex-col items-center justify-center gap-3 text-center text-sm text-white/65">
|
||||||
<Activity className="h-7 w-7 animate-pulse text-warning" aria-hidden />
|
<Activity className="h-7 w-7 animate-pulse text-warning" aria-hidden />
|
||||||
@ -254,11 +333,11 @@ export function SessionActivityPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
messages.map((message, index) => (
|
visibleMessages.map((message, index) => (
|
||||||
<ActivityMessage
|
<ActivityMessage
|
||||||
key={`${message.timestamp ?? "untimed"}-${index}`}
|
key={`${message.timestamp ?? "untimed"}-${hiddenMessageCount + index}`}
|
||||||
message={message}
|
message={message}
|
||||||
index={index}
|
index={hiddenMessageCount + index}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -279,12 +279,14 @@ data:
|
|||||||
|
|
||||||
## Atlas engineering access
|
## Atlas engineering access
|
||||||
|
|
||||||
Atlas repositories are private and canonical at
|
The Atlas organization has private visibility. Its repositories are access-
|
||||||
`https://scm.bstein.dev/atlas/<repo>.git`. HTTPS Git authentication is
|
controlled as either private or Gitea-internal, never public, and are
|
||||||
already supplied through `GIT_ASKPASS`. Verify the remote and cleanly
|
canonical at `https://scm.bstein.dev/atlas/<repo>.git`. HTTPS Git
|
||||||
separate pre-existing changes, create a task branch, run the repository's
|
authentication is already supplied through `GIT_ASKPASS`. Verify the remote
|
||||||
tests, use `git push --dry-run` when proving access, and push a real branch
|
and cleanly separate pre-existing changes, create a task branch, run the
|
||||||
only when the requested implementation is review-ready. Never force-push.
|
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
|
The terminal PATH contains the pinned operator tools. Start cluster work
|
||||||
with `kubectl config current-context`, read-only status/events/logs, and the
|
with `kubectl config current-context`, read-only status/events/logs, and the
|
||||||
|
|||||||
@ -125,6 +125,7 @@ spec:
|
|||||||
- |
|
- |
|
||||||
set -eu
|
set -eu
|
||||||
env_file=/opt/data/.env
|
env_file=/opt/data/.env
|
||||||
|
profile_file=/opt/data/home/.profile
|
||||||
mkdir -p \
|
mkdir -p \
|
||||||
/opt/data/home/.claude \
|
/opt/data/home/.claude \
|
||||||
/opt/data/home/.codex \
|
/opt/data/home/.codex \
|
||||||
@ -184,6 +185,18 @@ spec:
|
|||||||
upsert_env GIT_ASKPASS /opt/coordinator/gitea_askpass.sh
|
upsert_env GIT_ASKPASS /opt/coordinator/gitea_askpass.sh
|
||||||
upsert_env GIT_TERMINAL_PROMPT 0
|
upsert_env GIT_TERMINAL_PROMPT 0
|
||||||
chmod 0600 "${env_file}"
|
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
|
# Existing owner data is already written as uid/gid 10000. A
|
||||||
# recursive chown made every routine rollout walk the full 20Gi
|
# recursive chown made every routine rollout walk the full 20Gi
|
||||||
# workspace while the dashboard had no endpoint. Own only the
|
# workspace while the dashboard had no endpoint. Own only the
|
||||||
@ -208,6 +221,7 @@ spec:
|
|||||||
/opt/data/SOUL.md \
|
/opt/data/SOUL.md \
|
||||||
/opt/data/workspace/AGENTS.md \
|
/opt/data/workspace/AGENTS.md \
|
||||||
/opt/data/workspace/START-HERE.md \
|
/opt/data/workspace/START-HERE.md \
|
||||||
|
"${profile_file}" \
|
||||||
"${env_file}"
|
"${env_file}"
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
|
|||||||
@ -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:611c1642381d2a82ff03b55dda58bcab9a2e723c495dc298d1e9f938e94ed8d4
|
digest: sha256:de9463fab178916ff9c880af2f8dae7ffc77585e3f7b73e15f5369454ce0f3dd
|
||||||
resources:
|
resources:
|
||||||
- namespace.yaml
|
- namespace.yaml
|
||||||
- vault-serviceaccount.yaml
|
- vault-serviceaccount.yaml
|
||||||
|
|||||||
@ -159,6 +159,7 @@ EVENT_CALLBACK_SIGNATURE_BEFORE = ''' def _make_run_event_callback(self, run_
|
|||||||
|
|
||||||
EVENT_CALLBACK_SIGNATURE_AFTER = ''' _RUN_ACTIVITY_BYTES = 512_000
|
EVENT_CALLBACK_SIGNATURE_AFTER = ''' _RUN_ACTIVITY_BYTES = 512_000
|
||||||
_RUN_ACTIVITY_FILES = 256
|
_RUN_ACTIVITY_FILES = 256
|
||||||
|
_RUN_ACTIVITY_HEARTBEAT_SECONDS = 15.0
|
||||||
|
|
||||||
def _record_run_activity(
|
def _record_run_activity(
|
||||||
self,
|
self,
|
||||||
@ -189,6 +190,34 @@ EVENT_CALLBACK_SIGNATURE_AFTER = ''' _RUN_ACTIVITY_BYTES = 512_000
|
|||||||
label = labels.get(event_type)
|
label = labels.get(event_type)
|
||||||
if not session_id or not label:
|
if not session_id or not label:
|
||||||
return
|
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
|
# Tool names are bounded identifiers. All previews are arbitrary model
|
||||||
# or tool text and can contain prompts, arguments, paths, or credentials,
|
# or tool text and can contain prompts, arguments, paths, or credentials,
|
||||||
# so the activity projection must never persist them.
|
# so the activity projection must never persist them.
|
||||||
@ -203,7 +232,7 @@ EVENT_CALLBACK_SIGNATURE_AFTER = ''' _RUN_ACTIVITY_BYTES = 512_000
|
|||||||
entry = {
|
entry = {
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": f"Activity · {content}",
|
"content": f"Activity · {content}",
|
||||||
"timestamp": time.time(),
|
"timestamp": now,
|
||||||
"activity_event": event_type,
|
"activity_event": event_type,
|
||||||
}
|
}
|
||||||
root = Path(
|
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")
|
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:
|
def patch(source: Path, destination: Path) -> None:
|
||||||
"""Apply the narrow session-lineage extension and fail on upstream drift."""
|
"""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_BODY_BEFORE, "event callback body"),
|
||||||
(EVENT_CALLBACK_END_BEFORE, "event callback end"),
|
(EVENT_CALLBACK_END_BEFORE, "event callback end"),
|
||||||
(EVENT_CALLBACK_CALL_BEFORE, "event callback call"),
|
(EVENT_CALLBACK_CALL_BEFORE, "event callback call"),
|
||||||
|
(RUN_SWEEP_BEFORE, "run stream sweep"),
|
||||||
):
|
):
|
||||||
if marker not in content:
|
if marker not in content:
|
||||||
raise RuntimeError(f"Hermes API {message} patch context changed")
|
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_BODY_BEFORE, EVENT_CALLBACK_BODY_AFTER, 1)
|
||||||
content = content.replace(EVENT_CALLBACK_END_BEFORE, EVENT_CALLBACK_END_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(
|
destination.write_text(
|
||||||
content.replace(EVENT_CALLBACK_CALL_BEFORE, EVENT_CALLBACK_CALL_AFTER, 1),
|
content.replace(EVENT_CALLBACK_CALL_BEFORE, EVENT_CALLBACK_CALL_AFTER, 1),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
|
|||||||
@ -52,7 +52,11 @@ HELPER_REPLACEMENT = '''def _run_activity_messages(session_id: str) -> List[Dict
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/sessions/{session_id}/messages")
|
@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)
|
MESSAGES_BEFORE = ''' messages = db.get_messages(sid)
|
||||||
@ -64,7 +68,126 @@ MESSAGES_AFTER = ''' messages = [
|
|||||||
*_run_activity_messages(sid),
|
*_run_activity_messages(sid),
|
||||||
]
|
]
|
||||||
messages.sort(key=lambda item: float(item.get("timestamp") or 0.0))
|
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")
|
raise RuntimeError("Hermes dashboard activity helper context changed")
|
||||||
if MESSAGES_BEFORE not in content:
|
if MESSAGES_BEFORE not in content:
|
||||||
raise RuntimeError("Hermes dashboard messages context changed")
|
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(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.parent.mkdir(parents=True, exist_ok=True)
|
||||||
destination.write_text(
|
destination.write_text(
|
||||||
content.replace(MESSAGES_BEFORE, MESSAGES_AFTER, 1),
|
content.replace(MESSAGES_BEFORE, MESSAGES_AFTER, 1),
|
||||||
|
|||||||
@ -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 "Only the foreground durable worker owns its Kanban task lifecycle" in instructions
|
||||||
assert "must never complete, block, unblock, reclaim" in instructions
|
assert "must never complete, block, unblock, reclaim" in instructions
|
||||||
assert "task's final structured result itself" 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():
|
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 "SessionActivityPanel" in dockerfile
|
||||||
assert "include_children=${includeChildren}" in dockerfile
|
assert "include_children=${includeChildren}" in dockerfile
|
||||||
assert "_dashboard_session_is_active" 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 = (
|
activity = (
|
||||||
ROOT / "dockerfiles" / "hermes-session-activity-panel.tsx"
|
ROOT / "dockerfiles" / "hermes-session-activity-panel.tsx"
|
||||||
).read_text()
|
).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 "aria-busy={running && messages.length === 0}" in activity
|
||||||
assert 'detail.source !== "api_server"' not in activity
|
assert 'detail.source !== "api_server"' not in activity
|
||||||
assert "Poll-backed transcript; terminal rendering is not required." 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
|
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"
|
+ "tool start body\n"
|
||||||
+ module.EVENT_CALLBACK_END_BEFORE
|
+ module.EVENT_CALLBACK_END_BEFORE
|
||||||
+ module.EVENT_CALLBACK_CALL_BEFORE
|
+ module.EVENT_CALLBACK_CALL_BEFORE
|
||||||
|
+ module.RUN_SWEEP_BEFORE
|
||||||
+ "suffix\n",
|
+ "suffix\n",
|
||||||
encoding="utf-8",
|
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 'self._record_run_activity(session_id, "run.started")' in patched
|
||||||
assert 'detail = tool_name if event_type in {' in patched
|
assert 'detail = tool_name if event_type in {' in patched
|
||||||
assert 'if event_type == "subagent.tool"' 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):
|
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"
|
destination = tmp_path / "patched.py"
|
||||||
source.write_text(
|
source.write_text(
|
||||||
"prefix\n"
|
"prefix\n"
|
||||||
|
+ module.LATEST_ROWS_BEFORE
|
||||||
|
+ module.LATEST_SELECTION_BEFORE
|
||||||
+ module.HELPER_MARKER
|
+ module.HELPER_MARKER
|
||||||
+ " db = object()\n"
|
+ " db = object()\n"
|
||||||
+ module.MESSAGES_BEFORE
|
+ 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 "entries[-1_000:]" in patched
|
||||||
assert "*_run_activity_messages(sid)" in patched
|
assert "*_run_activity_messages(sid)" in patched
|
||||||
assert "messages.sort(" 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):
|
def test_legacy_api_sessions_are_nested_idempotently(tmp_path: Path):
|
||||||
|
|||||||
@ -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 "/bin/sh /opt/coordinator/install_agent_tools.sh" in installer["command"][2]
|
||||||
assert any(mount["name"] == "coordinator" for mount in installer["volumeMounts"])
|
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():
|
def test_owner_agent_uses_only_the_canonical_hostname():
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user