Merge branch 'main' of https://gitea.erhe.top/toor/hermes-ui
Build / build (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s

This commit is contained in:
2026-10-24 20:42:35 +08:00
5 changed files with 134 additions and 2 deletions
+2
View File
@@ -8,6 +8,8 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
dist
# Build backups made by hand or by deploy tooling (dist.first-turn-old/, dist.bak/ …)
dist.*/
playwright-report
test-results
dist-ssr
+64 -1
View File
@@ -24,6 +24,7 @@
set -euo pipefail
APP_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPT_PATH="$APP_DIR/$(basename "$0")"
cd "$APP_DIR"
SERVICE="${HERMES_UI_SERVICE:-hermes-ui.service}"
@@ -33,6 +34,10 @@ BACKUP_DIR="$STATE_DIR/backups"
LOCK_HASH_FILE="$STATE_DIR/lockfile.sha256"
KEEP_BACKUPS=3
# gitea is not directly reachable from every host; when a plain fetch fails
# the script retries through the local mihomo proxy.
GIT_PROXY="${HERMES_UI_GIT_PROXY:-http://127.0.0.1:7890}"
HEALTH_URL="http://127.0.0.1:$PORT/health"
AUTH_URL="http://127.0.0.1:$PORT/api/auth/status"
@@ -104,6 +109,31 @@ while [ $# -gt 0 ]; do
shift
done
# --- Escape the service cgroup --------------------------------------------
#
# This script restarts $SERVICE. When it is launched from inside that
# service's cgroup — e.g. from the agent bridge that the web UI itself
# runs — the restart SIGTERMs the whole cgroup, including this script.
# The build succeeds, the service comes back, but the script dies before it
# can run the post-restart health checks.
#
# Re-executing under a transient systemd scope moves the script into its
# own cgroup, so the restart no longer takes it down with it. A script
# started from a normal shell is not affected.
if [ "${HERMES_UI_DEPLOY_SCOPED:-0}" != "1" ] \
&& [ -r /proc/self/cgroup ] \
&& grep -q "$SERVICE" /proc/self/cgroup 2>/dev/null; then
if command -v systemd-run >/dev/null 2>&1; then
scope_unit="hermes-ui-deploy-$(date +%s)-$$"
export HERMES_UI_DEPLOY_SCOPED=1
echo "Running inside the $SERVICE cgroup — re-executing in scope $scope_unit"
exec systemd-run --scope --quiet --unit="$scope_unit" "$SCRIPT_PATH" "$@"
else
warn "systemd-run not found — the restart may kill this script early."
fi
fi
# --- Preflight ------------------------------------------------------------
step "Preflight"
@@ -137,6 +167,30 @@ if [ "$DRY_RUN" -eq 1 ]; then
fi
# --- Pull -----------------------------------------------------------------
#
# The deploy must always end up building the newest code on origin/main.
# Two things get in the way of that:
#
# * The branch diverged — the operator pushed from a dev machine while
# this host also had local commits. A merge commit would be recorded
# for every deploy; a rebase replays the local commits on top of
# origin/main and keeps the history linear.
# * origin is unreachable directly — gitea sits behind the network, so
# the fetch has to go through the local proxy.
#
# If the rebase hits a conflict the script stops instead of deploying a
# half-merged tree.
fetch_origin() {
if git fetch --quiet origin main 2>/dev/null; then
return 0
fi
if [ -n "$GIT_PROXY" ] && git -c "http.proxy=$GIT_PROXY" -c "https.proxy=$GIT_PROXY" \
fetch --quiet origin main 2>/dev/null; then
return 0
fi
return 1
}
if [ "$SKIP_PULL" -eq 1 ]; then
step "Skipping git pull (--skip-pull)"
@@ -149,10 +203,19 @@ else
else
# A failed fetch must not block a deploy: the point of the script is to
# build and restart what is already checked out.
if ! git fetch --quiet origin main; then
if ! fetch_origin; then
warn "Could not reach origin — continuing with the local checkout."
elif git merge --ff-only --quiet origin/main; then
ok "Updated to $(git rev-parse --short HEAD)"
elif [ -n "$(git rev-list --count origin/main..HEAD)" ]; then
# Diverged with local commits on top — rebase them onto origin/main.
step "Local commits found, rebasing onto origin/main"
if git rebase origin/main; then
ok "Rebased onto $(git rev-parse --short origin/main), now at $(git rev-parse --short HEAD)"
else
git rebase --abort || true
die "Rebase onto origin/main hit a conflict. Resolve it by hand and re-run."
fi
else
die "Fast-forward to origin/main failed. Resolve the branch by hand and re-run."
fi
@@ -0,0 +1,19 @@
---
date: 2026-10-22
feature: Agent Bridge startup
impact: Prevent first-turn connection loss during Hermes interpreter relaunch
commit: pending
---
# Initialize Hermes before Bridge readiness
- Commit: pending local validation.
- Feature: Agent Bridge process startup, first chat/context estimate.
- Cause: importing run_agent after the worker socket was ready invoked Hermes bootstrap, which execed from the selected dependency venv into managed Python and dropped the in-flight request.
- Change: run Hermes bootstrap in the manager launch command before starting the broker. Workers inherit the selected interpreter; vendored Python and Socket.IO contracts remain unchanged.
- Validation: focused manager and bridge tests, cold-worker context estimates and first chat, production build.
## Results
- Harness and full production build passed.
- Focused manager/concurrency suite: 54 passed; two pre-existing no-source tests also fail unchanged on this host because a global Hermes checkout exists.
- Cold magic-conch and snowy-owl context estimates succeeded; the first snowy-owl chat completed with OK.
- Focused browser chat streaming test passed with one worker. Broad parallel browser run: 3 passed, 8 input-locator failures; no client code changed.
@@ -312,6 +312,24 @@ export function resolveAgentBridgeCommand(options: AgentBridgeManagerOptions = {
return { command: fallback, argsPrefix: [], agentRoot, hermesHome }
}
export function buildAgentBridgeLaunchArgs(command: BridgeCommand, script: string): string[] {
const root = command.agentRoot
if (!root || !existsSync(join(root, 'hermes_bootstrap.py'))) {
return [...command.argsPrefix, script]
}
// Bootstrap may exec into Hermes's managed Python. Do that before the broker
// forks workers or opens sockets, so the first request survives initialization.
// Keep sys.argv[0] as -c until bootstrap returns so its relaunch replays this code.
const bootstrap = [
'import sys, runpy',
'sys.path.insert(0, ' + JSON.stringify(root) + ')',
'import hermes_bootstrap',
'sys.argv = sys.argv[1:]',
"runpy.run_path(sys.argv[0], run_name='__main__')",
].join('; ')
return [...command.argsPrefix, '-c', bootstrap, script]
}
function bridgeScriptPath(): string {
const candidates = [
// Built server: dist/server/index.js -> dist/server/agent-bridge/python/hermes_bridge.py
@@ -922,7 +940,7 @@ export class AgentBridgeManager {
const script = bridgeScriptPath()
const command = resolveAgentBridgeCommand(this.options)
await this.prepareEndpoint()
const args = [...command.argsPrefix, script, '--endpoint', this.endpoint]
const args = [...buildAgentBridgeLaunchArgs(command, script), '--endpoint', this.endpoint]
const agentRoot = command.agentRoot
const hermesHome = command.hermesHome
if (agentRoot) args.push('--agent-root', agentRoot)
+30
View File
@@ -1,3 +1,4 @@
import { execFileSync } from 'child_process'
import { EventEmitter } from 'events'
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { createServer, type Server } from 'net'
@@ -113,6 +114,35 @@ describe('agent bridge manager command resolution', () => {
})
})
it('bootstraps before the bridge starts even when bootstrap replaces the interpreter', async () => {
const root = join(tempDir, 'agent root')
const script = join(tempDir, 'bridge.py')
mkdirSync(root, { recursive: true })
writeFileSync(join(root, 'hermes_bootstrap.py'), [
'import os, sys',
"if os.environ.get('TEST_BRIDGE_BOOTSTRAPPED') != 'yes':",
" os.environ['TEST_BRIDGE_BOOTSTRAPPED'] = 'yes'",
' os.execv(sys.executable, [sys.executable, *sys.orig_argv[1:]])',
].join('\n'))
writeFileSync(script, [
'import json, os, sys',
"assert os.environ.get('TEST_BRIDGE_BOOTSTRAPPED') == 'yes'",
'print(json.dumps(sys.argv))',
].join('\n'))
const { buildAgentBridgeLaunchArgs } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
const args = buildAgentBridgeLaunchArgs({ command: 'python3', argsPrefix: [], agentRoot: root, hermesHome: tempDir }, script)
const env = { ...process.env }
delete env.TEST_BRIDGE_BOOTSTRAPPED
const output = execFileSync(process.platform === 'win32' ? 'python' : 'python3', [...args, '--endpoint', 'ipc:///test.sock'], { env, encoding: 'utf8', timeout: 10000 })
expect(JSON.parse(output.trim())).toEqual([script, '--endpoint', 'ipc:///test.sock'])
})
it('preserves legacy launch arguments when Hermes has no bootstrap module', async () => {
const { buildAgentBridgeLaunchArgs } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
expect(buildAgentBridgeLaunchArgs({ command: 'uv', argsPrefix: ['run', 'python'], agentRoot: tempDir, hermesHome: tempDir }, 'bridge.py'))
.toEqual(['run', 'python', 'bridge.py'])
})
it('injects Web UI OpenRouter attribution into the bridge process env by default', async () => {
const { buildAgentBridgeProcessEnv } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
const env = buildAgentBridgeProcessEnv('ipc:///tmp/test.sock', '/tmp/hermes-home', '/tmp/hermes-agent')