diff --git a/.gitignore b/.gitignore index c48b2bf..a075ff0 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/deploy.sh b/deploy.sh index f1a9674..953b248 100755 --- a/deploy.sh +++ b/deploy.sh @@ -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 diff --git a/docs/chat-chain-changes/2026-10-22-bridge-bootstrap-before-ready.md b/docs/chat-chain-changes/2026-10-22-bridge-bootstrap-before-ready.md new file mode 100644 index 0000000..6ee493d --- /dev/null +++ b/docs/chat-chain-changes/2026-10-22-bridge-bootstrap-before-ready.md @@ -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. diff --git a/packages/server/src/services/hermes/agent-bridge/manager.ts b/packages/server/src/services/hermes/agent-bridge/manager.ts index f8c056d..bd63ec4 100644 --- a/packages/server/src/services/hermes/agent-bridge/manager.ts +++ b/packages/server/src/services/hermes/agent-bridge/manager.ts @@ -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) diff --git a/tests/server/agent-bridge-manager.test.ts b/tests/server/agent-bridge-manager.test.ts index 1288504..e5d2316 100644 --- a/tests/server/agent-bridge-manager.test.ts +++ b/tests/server/agent-bridge-manager.test.ts @@ -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')