Merge remote-tracking branch 'origin/main' into wake-toggle-config

This commit is contained in:
Teknium 2026-07-28 13:24:23 -07:00
commit f5ff8fa4f8
No known key found for this signature in database
15 changed files with 483 additions and 64 deletions

View file

@ -10,5 +10,7 @@
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
</dict>
</plist>

View file

@ -10,5 +10,7 @@
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
</dict>
</plist>

View file

@ -5510,18 +5510,22 @@ function installContextMenu(window) {
})
}
// Microphone capture for the voice composer. The renderer drives mic access
// Microphone and camera capture. The voice composer drives mic access and
// renderer features (e.g. desktop plugins) can drive camera access, both
// through getUserMedia, which Chromium gates behind these two session hooks.
//
// The naive `details.mediaTypes.includes('audio')` check works on macOS but
// breaks on Windows: Chromium frequently fires the mic permission request with
// an empty/undefined `mediaTypes`, so the strict check denies it and
// getUserMedia throws NotAllowedError ("Microphone permission was denied").
// We therefore treat an audio-capture request as allowed whenever it's the
// 'media'/'audioCapture' permission AND mediaTypes either includes 'audio' OR
// is empty/absent (the Windows case). Video is still denied.
function isAudioCapturePermission(permission, details) {
if (permission === 'audioCapture') {
// breaks on Windows: Chromium frequently fires the request with an empty or
// undefined `mediaTypes`, so a strict check denies it and getUserMedia throws
// NotAllowedError. We therefore allow the capture permissions and treat absent
// metadata as allowed.
//
// Granting here is not the last gate: the OS still applies its own capture
// permission (macOS TCC prompts on first use, per the NSMicrophone/NSCamera
// usage strings), so the user keeps a real allow/deny and can revoke it in
// System Settings afterwards.
function isMediaCapturePermission(permission, details) {
if (permission === 'audioCapture' || permission === 'videoCapture') {
return true
}
@ -5531,38 +5535,31 @@ function isAudioCapturePermission(permission, details) {
const mediaTypes = details?.mediaTypes
// Windows: mediaTypes is often empty for a capture request. Don't deny on
// missing metadata.
if (!Array.isArray(mediaTypes) || mediaTypes.length === 0) {
// Windows: mediaTypes is often empty for a mic request. Don't deny on
// missing metadata. (A video request would carry mediaTypes:['video'].)
return true
}
return mediaTypes.includes('audio') && !mediaTypes.includes('video')
return mediaTypes.includes('audio') || mediaTypes.includes('video')
}
function installMediaPermissions() {
// Async request handler: the prompt-style path (most platforms).
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback, details) => {
callback(isAudioCapturePermission(permission, details))
callback(isMediaCapturePermission(permission, details))
})
// Synchronous check handler: Chromium consults this for getUserMedia on
// Windows in addition to (or instead of) the request handler. Without it,
// the check defaults to false and the mic is denied before the request
// the check defaults to false and capture is denied before the request
// handler ever runs.
session.defaultSession.setPermissionCheckHandler((_webContents, permission, _origin, details) => {
if (permission === 'media' || permission === ('audioCapture' as any) /* todo: is this needed? */) {
// details.mediaType is a single string here (not the mediaTypes array).
const mediaType = details?.mediaType
if (mediaType === 'video') {
return false
}
return true
}
return false
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
return (
permission === 'media' ||
permission === ('audioCapture' as any) /* todo: is this needed? */ ||
permission === ('videoCapture' as any)
)
})
}
@ -8023,6 +8020,17 @@ async function spawnPoolBackend(profile, entry) {
entry.token = authToken
// Verify the WebSocket session token before declaring backend ready.
// HTTP /api/status can pass while WS auth fails (separate transport, separate guards).
const wsUrl = `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`
const wsProbe = await probeGatewayWebSocket(wsUrl, { WebSocketImpl: globalThis.WebSocket })
if (!wsProbe.ok) {
throw new Error(
`Hermes backend for profile "${profile}" is HTTP-reachable but the WebSocket (/api/ws) rejected the session token: ${wsProbe.reason}`
)
}
return {
baseUrl,
mode: 'local',
@ -8030,7 +8038,7 @@ async function spawnPoolBackend(profile, entry) {
authMode: 'token',
token: authToken,
profile,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
wsUrl,
logs: hermesLog.slice(-80),
...getWindowState()
}
@ -8348,6 +8356,16 @@ async function startHermes() {
rememberLog
})
// Verify the WebSocket session token before declaring backend ready.
const wsUrl = `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`
const wsProbe = await probeGatewayWebSocket(wsUrl, { WebSocketImpl: globalThis.WebSocket })
if (!wsProbe.ok) {
throw new Error(
`Local Hermes backend is HTTP-reachable but the WebSocket (/api/ws) rejected the session token: ${wsProbe.reason}`
)
}
updateBootProgress({
phase: 'backend.ready',
message: 'Hermes backend is ready. Finalizing desktop startup',
@ -8362,7 +8380,7 @@ async function startHermes() {
source: 'local',
authMode: 'token',
token: authToken,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
wsUrl,
logs: hermesLog.slice(-80),
...getWindowState()
}

View file

@ -220,6 +220,7 @@
"CFBundleExecutable": "Hermes",
"CFBundleName": "Hermes",
"NSAudioCaptureUsageDescription": "Hermes uses audio capture for voice conversations.",
"NSCameraUsageDescription": "Hermes uses the camera when a plugin or feature you enable requests it.",
"NSMicrophoneUsageDescription": "Hermes uses the microphone for voice input and voice conversations."
},
"gatekeeperAssess": false,

View file

@ -169,7 +169,12 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses
}
if (attachment.kind === 'image' || attachment.kind === 'file') {
const next = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId })
const next = await uploadComposerAttachment(attachment, {
backendCwd: readState()?.cwd,
remote,
requestGateway,
sessionId
})
if (options.updateComposerAttachments ?? true) {
scope.attachments.update(next)

View file

@ -82,6 +82,33 @@ describe('readProjectDir', () => {
expect(readFileDataUrl).toHaveBeenCalledWith('C:/repo/.gitignore')
})
it('filters gitignored entries when Windows path casing differs across IPC results', async () => {
gitRoot.mockResolvedValue('C:\\Repo')
readDir.mockImplementation(async path => {
if (path === 'c:\\repo\\src') {
return ok([
{ name: 'debug.log', path: 'c:\\repo\\src\\debug.log', isDirectory: false },
{ name: 'keep.ts', path: 'c:\\repo\\src\\keep.ts', isDirectory: false }
])
}
if (path === 'C:/Repo') {
return ok([{ name: '.gitignore', path: 'C:/Repo/.gitignore', isDirectory: false }])
}
if (path === 'C:/Repo/src') {
return ok([])
}
return ok([])
})
readFileDataUrl.mockResolvedValue(dataUrl('src/*.log\n'))
const result = await readProjectDir('c:\\repo\\src', 'c:\\repo')
expect(result.entries.map(entry => entry.name)).toEqual(['keep.ts'])
})
it('does not fetch .gitignore contents when listings do not contain .gitignore', async () => {
gitRoot.mockResolvedValue('/repo')
readDir.mockImplementation(async path => {

View file

@ -32,16 +32,25 @@ function clean(path: string) {
return path.replace(/\\/g, '/').replace(/\/+$/, '') || '/'
}
// Windows path identity is case-insensitive. Fold only comparison keys so the
// relative path returned below keeps the filesystem's original spelling;
// POSIX paths remain case-sensitive.
function comparisonPath(path: string) {
return /^[A-Za-z]:(?:\/|$)/.test(path) || path.startsWith('//') ? path.toLowerCase() : path
}
/** Strict POSIX-style relative path; null if `child` is not inside `root`. */
function relativeTo(root: string, child: string) {
const r = clean(root)
const c = clean(child)
const rKey = comparisonPath(r)
const cKey = comparisonPath(c)
if (c === r) {
if (cKey === rKey) {
return ''
}
return c.startsWith(`${r}/`) ? c.slice(r.length + 1) : null
return cKey.startsWith(`${rKey}/`) ? c.slice(r.length + 1) : null
}
/** Repo-root → repo-root/a → repo-root/a/b → … for every dir between root and `dir`. */

View file

@ -12,6 +12,7 @@ import { $notifications, clearNotifications } from '@/store/notifications'
import {
$busy,
$connection,
$currentCwd,
$currentUsage,
$messages,
$sessions,
@ -2128,6 +2129,7 @@ describe('usePromptActions file attachment sync', () => {
afterEach(() => {
cleanup()
$connection.set(null)
$currentCwd.set('')
vi.restoreAllMocks()
})
@ -2190,6 +2192,100 @@ describe('usePromptActions file attachment sync', () => {
})
})
it('uploads Windows file bytes when local mode fronts a POSIX WSL/Docker backend', async () => {
$connection.set({ mode: 'local' } as never)
$currentCwd.set('/root')
const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,aGVsbG8=')
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl }
})
const attachment: ComposerAttachment = {
...fileAttachment(),
path: 'C:\\Users\\alice\\Downloads\\report.txt',
refText: '@file:`C:\\Users\\alice\\Downloads\\report.txt`'
}
const calls: { method: string; params?: Record<string, unknown> }[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'file.attach') {
return {
attached: true,
path: '/root/.hermes/desktop-attachments/report.txt',
ref_text: '@file:.hermes/desktop-attachments/report.txt',
uploaded: true
} as never
}
return {} as never
})
let handle: HarnessHandle | null = null
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
expect(await handle!.submitText('summarize', { attachments: [attachment] })).toBe(true)
expect(readFileDataUrl).toHaveBeenCalledWith('C:\\Users\\alice\\Downloads\\report.txt')
expect(calls[0]).toEqual({
method: 'file.attach',
params: {
data_url: 'data:text/plain;base64,aGVsbG8=',
name: 'report.txt',
path: 'C:\\Users\\alice\\Downloads\\report.txt',
session_id: RUNTIME_SESSION_ID
}
})
expect(calls[1]).toEqual({
method: 'prompt.submit',
params: { session_id: RUNTIME_SESSION_ID, text: '@file:.hermes/desktop-attachments/report.txt\n\nsummarize' }
})
})
it('uses image.attach_bytes for a Windows image when the local backend cwd is POSIX', async () => {
const readFileDataUrl = vi.fn(async () => 'data:image/jpeg;base64,aGVsbG8=')
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl }
})
const requestGateway = vi.fn(async (method: string) => {
if (method === 'image.attach_bytes') {
return { attached: true, path: '/root/tmp/photo.jpg' } as never
}
return {} as never
})
const uploaded = await uploadComposerAttachment(
{
id: 'image:photo.jpg',
kind: 'image',
label: 'photo.jpg',
path: 'C:\\Users\\alice\\Pictures\\photo.jpg'
},
{
backendCwd: '/root',
remote: false,
requestGateway,
sessionId: RUNTIME_SESSION_ID
}
)
expect(readFileDataUrl).toHaveBeenCalledWith('C:\\Users\\alice\\Pictures\\photo.jpg')
expect(requestGateway).toHaveBeenCalledWith('image.attach_bytes', {
content_base64: 'aGVsbG8=',
filename: 'photo.jpg',
session_id: RUNTIME_SESSION_ID
})
expect(requestGateway).not.toHaveBeenCalledWith('image.attach', expect.anything())
expect(uploaded.path).toBe('/root/tmp/photo.jpg')
})
it('passes a path-less @file: ref straight through (no path = nothing to upload)', async () => {
// Submit-layer contract: only attachments that carry a `path` are upload
// candidates. A path-less ref (an @-mention/context ref or pasted text)
@ -2237,8 +2333,20 @@ describe('usePromptActions file attachment sync', () => {
expect(calls[0]?.params?.text).toContain('@file:`/Users/mahmoud/Downloads/DEVIS_signed.pdf`')
})
it('passes the path directly via file.attach in local mode (no byte upload)', async () => {
it('passes a Windows path directly for a native Windows local backend', async () => {
$connection.set({ mode: 'local' } as never)
$currentCwd.set('C:\\Users\\alice\\project')
const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,c2hvdWxkLW5vdC1iZS1yZWFk')
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl }
})
const attachment: ComposerAttachment = {
...fileAttachment(),
path: 'C:\\Users\\alice\\Downloads\\report.txt',
refText: '@file:`C:\\Users\\alice\\Downloads\\report.txt`'
}
const calls: { method: string; params?: Record<string, unknown> }[] = []
@ -2257,11 +2365,12 @@ describe('usePromptActions file attachment sync', () => {
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
const ok = await handle!.submitText('summarize', { attachments: [fileAttachment()] })
const ok = await handle!.submitText('summarize', { attachments: [attachment] })
expect(ok).toBe(true)
expect(calls[0]?.method).toBe('file.attach')
// Local mode sends no data_url — the gateway shares this disk.
expect(readFileDataUrl).not.toHaveBeenCalled()
// Native Windows local mode shares the same path namespace.
expect(calls[0]?.params).not.toHaveProperty('data_url')
expect(calls[1]).toEqual({
method: 'prompt.submit',

View file

@ -25,6 +25,7 @@ import { clearAllPrompts } from '@/store/prompts'
import {
$busy,
$connection,
$currentCwd,
$messages,
setAwaitingResponse,
setBusy,
@ -76,26 +77,38 @@ interface HandoffResult {
error?: string
}
const WINDOWS_ABSOLUTE_PATH_RE = /^(?:[A-Za-z]:[\\/]|\\\\)/
const POSIX_ABSOLUTE_PATH_RE = /^\/(?!\/)/
// `mode: local` means the gateway was launched locally, not necessarily that
// Electron and the gateway share a filesystem. Windows Desktop can front a
// WSL/Docker backend whose cwd is POSIX, so a Windows host path must cross the
// boundary as bytes just like a remote attachment.
function attachmentPathNeedsUpload(path: string, backendCwd?: null | string): boolean {
return WINDOWS_ABSOLUTE_PATH_RE.test(path.trim()) && POSIX_ABSOLUTE_PATH_RE.test(backendCwd?.trim() || '')
}
/**
* Stage one file/image attachment into the session workspace and return the
* attachment rewritten with the gateway-side ref. Images upload their bytes in
* remote mode (so vision works) and pass the path locally; non-image files
* upload bytes remotely and pass the path locally. Throws on failure so callers
* can surface an error. Shared by submit-time sync, the eager drop-time upload,
* and the message-edit composer drop keep them in lockstep.
* attachment rewritten with the gateway-side ref. Attachments upload their
* bytes for remote gateways and local cross-filesystem backends; otherwise the
* gateway receives the shared local path. Throws on failure so callers can
* surface an error. Shared by submit-time sync, the eager drop-time upload, and
* the message-edit composer drop keep them in lockstep.
*/
export async function uploadComposerAttachment(
attachment: ComposerAttachment,
opts: { remote: boolean; requestGateway: GatewayRequest; sessionId: string }
opts: { backendCwd?: null | string; remote: boolean; requestGateway: GatewayRequest; sessionId: string }
): Promise<ComposerAttachment> {
const { remote, requestGateway, sessionId } = opts
const { backendCwd, remote, requestGateway, sessionId } = opts
const path = attachment.path ?? ''
const label = attachment.label || pathLabel(path)
const uploadBytes = remote || attachmentPathNeedsUpload(path, backendCwd)
if (attachment.kind === 'image') {
let result: ImageAttachResponse
if (remote) {
if (uploadBytes) {
let payload: Awaited<ReturnType<typeof readImageForRemoteAttach>>
try {
@ -138,7 +151,7 @@ export async function uploadComposerAttachment(
// Non-image file.
let dataUrl: string | null = null
if (remote) {
if (uploadBytes) {
try {
dataUrl = await readFileDataUrlForAttach(path)
} catch (err) {
@ -317,7 +330,12 @@ export function usePromptActions({
}
if (attachment.kind === 'image' || attachment.kind === 'file') {
const nextAttachment = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId })
const nextAttachment = await uploadComposerAttachment(attachment, {
backendCwd: $currentCwd.get(),
remote,
requestGateway,
sessionId
})
// Update-only: never resurrect a chip the user removed mid-upload.
if (updateComposerAttachments) {
@ -356,7 +374,14 @@ export function usePromptActions({
try {
// Update-only: if the user removed the chip while this was uploading,
// don't resurrect it — just drop the staged result on the floor.
updateComposerAttachment(await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId }))
updateComposerAttachment(
await uploadComposerAttachment(attachment, {
backendCwd: $currentCwd.get(),
remote,
requestGateway,
sessionId
})
)
} catch (err) {
// Leave the chip in place so submit-time sync can retry (or the user can
// remove it) and flag the card; also toast so a hard failure (unreadable

View file

@ -415,7 +415,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
try {
const uploaded = await uploadComposerAttachment(
{ detail: path, id: attachmentId(kind, path), kind, label: pathLabel(path), path },
{ remote, requestGateway, sessionId }
{ backendCwd: cwd, remote, requestGateway, sessionId }
)
const ref = attachmentDisplayText(uploaded)

View file

@ -351,7 +351,17 @@ class _Runtime:
with session.lock:
if session.closing:
return
self._finish_task(session, task_id, event)
finished = self._finish_task(session, task_id, event)
if finished:
try:
self.relay.subscribers.flush()
except Exception:
logger.warning(
"Hermes shared-metrics task flush failed",
exc_info=True,
)
else:
self._export()
def close_session(self, event: dict[str, Any]) -> None:
session = self._session(event)
@ -380,7 +390,8 @@ class _Runtime:
self.relay.subscribers.flush()
except Exception as exc:
failures.append(f"subscriber flush failed: {exc}")
self._export()
else:
self._export()
with self._sessions_lock:
if self._sessions.get(session.session_id) is session:
self._sessions.pop(session.session_id, None)
@ -399,8 +410,15 @@ class _Runtime:
self._safe(self.close_session, {"session_id": session_id})
if not self._registered:
return
self._safe(self.relay.subscribers.flush)
self._export()
try:
self.relay.subscribers.flush()
except Exception:
logger.warning(
"Hermes shared-metrics shutdown flush failed",
exc_info=True,
)
else:
self._export()
self._safe(self.relay.subscribers.deregister, self._subscriber_name)
self.host.release_managed_execution(self._subscriber_name)
self._registered = False
@ -560,10 +578,10 @@ class _Runtime:
session: _MetricsSession,
task_id: str,
event: dict[str, Any],
) -> None:
) -> bool:
task = session.tasks.get(task_id)
if task is None:
return
return False
self._end_pending_model_calls(session, {**event, "task_id": task_id})
fields = task_terminal_fields(
{**task.start_fields, **event},
@ -592,9 +610,10 @@ class _Runtime:
turn_key = (session.session_id, turn_id)
if self._turn_sessions.get(turn_key) is session:
self._turn_sessions.pop(turn_key, None)
return True
def _export(self) -> None:
self._safe(self.subscriber.store.create_and_export_package)
self._safe(self.subscriber.store.create_and_export_package_if_due)
def _event_metadata(self) -> dict[str, str]:
return {

View file

@ -114,6 +114,14 @@ class SharedMetricsStore:
for _ in range(pending_periods):
if self._create_package() is None:
break
return self._export_and_prune()
def create_and_export_package_if_due(self) -> list[Path]:
"""Create pending packages at most once per UTC day, then export them."""
self._create_pending_packages_if_due()
return self._export_and_prune()
def _export_and_prune(self) -> list[Path]:
exported = self._export_pending_packages()
try:
self._prune_expired_history()
@ -281,6 +289,26 @@ class SharedMetricsStore:
).fetchone()
return int(row["period_count"]) if row is not None else 0
def _create_pending_packages_if_due(self) -> None:
now = _utc_now()
with self._connection() as connection:
with write_txn(connection):
# Gate on the committed package, not its file write, so a failed
# outbox export can be retried without packaging deltas twice.
package_created_today = connection.execute(
"""
SELECT 1
FROM package_outbox
WHERE substr(created_at, 1, 10) >= ?
LIMIT 1
""",
(now.date().isoformat(),),
).fetchone()
if package_created_today is not None:
return
while self._create_package_in_transaction(connection, now) is not None:
pass
def _create_package(self) -> dict[str, Any] | None:
now = _utc_now()
with self._connection() as connection:

View file

@ -18,6 +18,7 @@ from types import SimpleNamespace
from typing import Any
import pytest
from hermes_cli.observability import shared_metrics as shared_metrics_module
from hermes_cli.observability.shared_metrics import SharedMetricsStore
from hermes_cli.observability.shared_metrics_contract import (
COUNT_BUCKETS,
@ -142,6 +143,43 @@ def test_model_call_counter_survives_restart_and_exports_only_new_deltas(tmp_pat
assert restarted.counter_snapshot()[0]["packaged_value"] == 3
def test_due_export_runs_once_per_utc_day_and_catches_up_pending_deltas(
tmp_path, monkeypatch
):
current_time = datetime(2026, 7, 28, 9, tzinfo=timezone.utc)
monkeypatch.setattr(shared_metrics_module, "_utc_now", lambda: current_time)
store = SharedMetricsStore(tmp_path / "metrics.sqlite3", tmp_path / "outbox")
store.record_model_call(_dimensions(), "test-version")
assert len(store.create_and_export_package_if_due()) == 1
current_time = datetime(2026, 7, 28, 18, tzinfo=timezone.utc)
store = SharedMetricsStore(tmp_path / "metrics.sqlite3", tmp_path / "outbox")
store.record_model_call(_dimensions(), "test-version")
assert store.create_and_export_package_if_due() == []
assert len(list((tmp_path / "outbox").glob("*.json"))) == 1
assert store.counter_snapshot()[0] == {
"period_start": "2026-07-28",
"metric_name": "hermes.model_call.count",
"hermes_version": "test-version",
"dimensions": _dimensions(),
"value": 2,
"packaged_value": 1,
}
current_time = datetime(2026, 7, 29, 9, tzinfo=timezone.utc)
store.record_model_call(_dimensions(), "test-version")
assert len(store.create_and_export_package_if_due()) == 2
assert len(list((tmp_path / "outbox").glob("*.json"))) == 3
assert all(
row["value"] == row["packaged_value"] for row in store.counter_snapshot()
)
store.record_model_call(_dimensions(), "test-version")
assert store.create_and_export_package_if_due() == []
assert len(list((tmp_path / "outbox").glob("*.json"))) == 3
def test_package_schema_matches_the_model_call_contract():
properties = _package_dimension_schema()["properties"]
@ -758,6 +796,32 @@ def test_concurrent_package_builders_commit_one_delta(tmp_path):
assert store.counter_snapshot()[0]["packaged_value"] == 1
def test_concurrent_due_exports_create_one_daily_package(tmp_path):
database_path = tmp_path / "metrics.sqlite3"
outbox_directory = tmp_path / "outbox"
store = SharedMetricsStore(database_path, outbox_directory)
store.record_model_call(_dimensions(), "test-version")
ready = threading.Barrier(8)
def export() -> None:
worker_store = SharedMetricsStore(database_path, outbox_directory)
ready.wait(timeout=5)
worker_store.create_and_export_package_if_due()
with ThreadPoolExecutor(max_workers=8) as executor:
futures = [executor.submit(export) for _ in range(8)]
for future in futures:
future.result()
with sqlite3.connect(database_path) as connection:
[outbox_count] = connection.execute(
"SELECT COUNT(*) FROM package_outbox"
).fetchone()
assert outbox_count == 1
assert len(list(outbox_directory.glob("*.json"))) == 1
assert store.counter_snapshot()[0]["packaged_value"] == 1
def test_concurrent_model_call_updates_are_transactional(tmp_path):
database_path = tmp_path / "metrics.sqlite3"
outbox_directory = tmp_path / "outbox"

View file

@ -5,7 +5,9 @@ from __future__ import annotations
import contextvars
import asyncio
import json
import sqlite3
import threading
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
from typing import Any
@ -321,6 +323,7 @@ def test_direct_runtime_records_without_enabling_a_plugin(direct_runtime, tmp_pa
def test_real_binding_drives_lifecycle_aggregation_export_and_snapshot(
real_binding_runtime,
tmp_path,
monkeypatch,
):
assert real_binding_runtime._native is not None
prompt_canary = "real-relay-sensitive-prompt"
@ -416,6 +419,12 @@ def test_real_binding_drives_lifecycle_aggregation_export_and_snapshot(
root = tmp_path / "hermes-home" / "telemetry" / "shared_metrics"
store = SharedMetricsStore(root / "metrics.sqlite3", root / "outbox")
tomorrow = datetime.now(timezone.utc) + timedelta(days=1)
monkeypatch.setattr(
"hermes_cli.observability.shared_metrics._utc_now",
lambda: tomorrow,
)
assert len(store.create_and_export_package_if_due()) == 1
snapshot = store.counter_snapshot()
by_metric: dict[str, list[dict[str, Any]]] = {}
for counter in snapshot:
@ -451,7 +460,7 @@ def test_real_binding_drives_lifecycle_aggregation_export_and_snapshot(
}
package_values: dict[tuple[str, tuple[tuple[str, str], ...]], int] = {}
packages = sorted((root / "outbox").glob("*.json"))
assert len(packages) == 3
assert len(packages) == 2
package_payloads = [
json.loads(package.read_text(encoding="utf-8")) for package in packages
]
@ -2279,32 +2288,133 @@ def test_session_finalize_closes_a_pending_task_as_system_aborted(direct_runtime
}
def test_sequential_tasks_in_one_session_aggregate_once_each(direct_runtime, tmp_path):
def test_desktop_task_completion_exports_once_per_utc_day(
direct_runtime, tmp_path, monkeypatch
):
current_time = datetime(2026, 7, 28, 9, tzinfo=timezone.utc)
monkeypatch.setattr(
"hermes_cli.observability.shared_metrics._utc_now",
lambda: current_time,
)
for task_id in ("t1", "t2"):
lifecycle.invoke_hook(
"pre_llm_call",
session_id="s1",
task_id=task_id,
platform="cli",
platform="desktop",
)
lifecycle.invoke_hook(
"on_session_end",
session_id="s1",
task_id=task_id,
platform="cli",
platform="desktop",
completed=True,
failed=False,
interrupted=False,
turn_exit_reason="text_response(stop)",
)
lifecycle.finalize_session(session_id="s1")
outbox = tmp_path / "hermes-home" / "telemetry" / "shared_metrics" / "outbox"
[package_path] = list(outbox.glob("*.json"))
[first_package_path] = list(outbox.glob("*.json"))
first_package = json.loads(first_package_path.read_text(encoding="utf-8"))
first_metrics = {metric["name"]: metric for metric in first_package["metrics"]}
assert first_metrics["hermes.task_run.started"]["value"] == 1
assert first_metrics["hermes.task_run.started"]["dimensions"] == {
"entrypoint": "interactive",
"execution_surface": "desktop",
}
assert first_metrics["hermes.task_run.finished"]["value"] == 1
lifecycle.finalize_session(session_id="s1")
assert list(outbox.glob("*.json")) == [first_package_path]
current_time = datetime(2026, 7, 29, 9, tzinfo=timezone.utc)
lifecycle.invoke_hook(
"pre_llm_call",
session_id="s1",
task_id="t3",
platform="desktop",
)
lifecycle.invoke_hook(
"on_session_end",
session_id="s1",
task_id="t3",
platform="desktop",
completed=True,
failed=False,
interrupted=False,
turn_exit_reason="text_response(stop)",
)
packages = [
json.loads(package_path.read_text(encoding="utf-8"))
for package_path in outbox.glob("*.json")
]
totals: dict[str, int] = {}
for package in packages:
for metric in package["metrics"]:
totals[metric["name"]] = totals.get(metric["name"], 0) + metric["value"]
assert totals["hermes.task_run.started"] == 3
assert totals["hermes.task_run.finished"] == 3
def test_failed_flush_keeps_daily_export_open_for_later_task(
direct_runtime, tmp_path, monkeypatch, caplog
):
current_time = datetime(2026, 7, 28, 9, tzinfo=timezone.utc)
monkeypatch.setattr(
"hermes_cli.observability.shared_metrics._utc_now",
lambda: current_time,
)
original_flush = direct_runtime.subscribers.flush
flush_attempts = 0
def fail_first_flush() -> None:
nonlocal flush_attempts
flush_attempts += 1
if flush_attempts == 1:
raise RuntimeError("simulated flush failure")
original_flush()
direct_runtime.subscribers.flush = fail_first_flush
def finish_desktop_task(task_id: str) -> None:
lifecycle.invoke_hook(
"pre_llm_call",
session_id="s1",
task_id=task_id,
platform="desktop",
)
lifecycle.invoke_hook(
"on_session_end",
session_id="s1",
task_id=task_id,
platform="desktop",
completed=True,
failed=False,
interrupted=False,
turn_exit_reason="text_response(stop)",
)
finish_desktop_task("t1")
root = tmp_path / "hermes-home" / "telemetry" / "shared_metrics"
assert list((root / "outbox").glob("*.json")) == []
with sqlite3.connect(root / "metrics.sqlite3") as connection:
[package_count] = connection.execute(
"SELECT COUNT(*) FROM package_outbox"
).fetchone()
assert package_count == 0
finish_desktop_task("t2")
[package_path] = list((root / "outbox").glob("*.json"))
package = json.loads(package_path.read_text(encoding="utf-8"))
metrics = {metric["name"]: metric for metric in package["metrics"]}
assert metrics["hermes.task_run.started"]["value"] == 2
assert metrics["hermes.task_run.finished"]["value"] == 2
assert flush_attempts == 2
assert "Hermes shared-metrics task flush failed" in caplog.text
def test_task_ownership_survives_session_id_rotation(direct_runtime):

View file

@ -143,7 +143,7 @@ def test_generate_xai_tts_uses_oauth_pinned_base_url(tmp_path, monkeypatch):
def raise_for_status(self):
pass
def fake_post(url, headers, json, timeout):
def fake_post(url, headers, json, timeout, stream=False):
captured["url"] = url
captured["headers"] = headers
return FakeResponse()
@ -590,7 +590,7 @@ def test_generate_xai_tts_omits_text_normalization_by_default(tmp_path, monkeypa
fake_response.content = b"mp3"
fake_response.raise_for_status.return_value = None
def fake_post(url, headers, json, timeout):
def fake_post(url, headers, json, timeout, stream=False):
captured["json"] = json
return fake_response
@ -614,7 +614,7 @@ def test_generate_xai_tts_sends_text_normalization_when_enabled(tmp_path, monkey
fake_response.content = b"mp3"
fake_response.raise_for_status.return_value = None
def fake_post(url, headers, json, timeout):
def fake_post(url, headers, json, timeout, stream=False):
captured["json"] = json
return fake_response
@ -640,7 +640,7 @@ def test_generate_xai_tts_omits_text_normalization_when_explicit_false(
fake_response.content = b"mp3"
fake_response.raise_for_status.return_value = None
def fake_post(url, headers, json, timeout):
def fake_post(url, headers, json, timeout, stream=False):
captured["json"] = json
return fake_response