mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
Merge pull request #72230 from NousResearch/bb/bootstrap-marker-triage
fix: make the bootstrap-complete marker consistent across every install path
This commit is contained in:
commit
48bdde1deb
7 changed files with 551 additions and 59 deletions
|
|
@ -12,11 +12,11 @@
|
|||
//! 4. Worker iterates stages, calling `install.ps1 -Stage NAME -NonInteractive -Json`.
|
||||
//! 5. On success → `complete`. On any stage failure → `failed`. On cancel → `failed`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
|
|
@ -260,6 +260,107 @@ pub(crate) fn hermes_is_installed(install_root: &std::path::Path) -> bool {
|
|||
&& resolve_hermes_desktop_exe(install_root).is_some()
|
||||
}
|
||||
|
||||
fn resolve_marker_commit(install_root: &Path, pin: &Pin) -> Option<String> {
|
||||
if let Some(commit) = pin
|
||||
.commit
|
||||
.as_ref()
|
||||
.filter(|commit| !commit.trim().is_empty())
|
||||
{
|
||||
return Some(commit.clone());
|
||||
}
|
||||
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.current_dir(install_root)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if commit.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(commit)
|
||||
}
|
||||
}
|
||||
|
||||
fn write_bootstrap_complete_marker(install_root: &Path, pin: &Pin) -> Result<serde_json::Value> {
|
||||
use std::io::Write;
|
||||
|
||||
let marker_path = crate::paths::likely_bootstrap_marker(install_root);
|
||||
if let Some(parent) = marker_path.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| {
|
||||
format!(
|
||||
"could not create bootstrap marker directory {}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let completed_at_unix = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or_default();
|
||||
let marker = serde_json::json!({
|
||||
"schemaVersion": 1,
|
||||
"pinnedCommit": resolve_marker_commit(install_root, pin),
|
||||
"pinnedBranch": pin.branch.clone(),
|
||||
"completedAtUnix": completed_at_unix,
|
||||
});
|
||||
let mut body = serde_json::to_vec_pretty(&marker)?;
|
||||
body.push(b'\n');
|
||||
|
||||
// Atomic publish (temp sibling + flush + rename), matching Electron's
|
||||
// writeFileAtomic(). hermes_is_installed() only checks existence, so a
|
||||
// partial direct write would incorrectly enable the launcher fast path.
|
||||
let tmp_path = install_root.join(".hermes-bootstrap-complete.tmp");
|
||||
{
|
||||
let mut file = std::fs::File::create(&tmp_path).with_context(|| {
|
||||
format!(
|
||||
"could not create temp bootstrap marker {}",
|
||||
tmp_path.display()
|
||||
)
|
||||
})?;
|
||||
file.write_all(&body).with_context(|| {
|
||||
format!(
|
||||
"could not write temp bootstrap marker {}",
|
||||
tmp_path.display()
|
||||
)
|
||||
})?;
|
||||
file.sync_all().with_context(|| {
|
||||
format!(
|
||||
"could not flush temp bootstrap marker {}",
|
||||
tmp_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
// Windows rename fails if the destination already exists; drop any prior
|
||||
// marker first so a re-run can still publish a fresh payload.
|
||||
if marker_path.exists() {
|
||||
std::fs::remove_file(&marker_path).with_context(|| {
|
||||
format!(
|
||||
"could not replace existing bootstrap marker {}",
|
||||
marker_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
if let Err(err) = std::fs::rename(&tmp_path, &marker_path) {
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
return Err(err).with_context(|| {
|
||||
format!(
|
||||
"could not publish bootstrap marker {} → {}",
|
||||
tmp_path.display(),
|
||||
marker_path.display()
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
tracing::info!(path = %marker_path.display(), "bootstrap marker written");
|
||||
Ok(marker)
|
||||
}
|
||||
|
||||
/// Spawn the already-built desktop app, detached. Returns Err if no built app
|
||||
/// exists or the spawn fails, so the caller can fall back to showing the
|
||||
/// installer UI.
|
||||
|
|
@ -644,6 +745,23 @@ async fn run_bootstrap(
|
|||
.unwrap_or_else(|| crate::paths::hermes_home().to_string_lossy().into_owned());
|
||||
let install_root = PathBuf::from(&hermes_home).join("hermes-agent");
|
||||
|
||||
// Marker publish is terminal for this run: a write failure must emit Failed
|
||||
// so the UI leaves the progress state (it does not poll get_bootstrap_status).
|
||||
let marker = match write_bootstrap_complete_marker(&install_root, &pin) {
|
||||
Ok(marker) => marker,
|
||||
Err(err) => {
|
||||
let msg = format!("write bootstrap marker failed: {err:#}");
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: None,
|
||||
error: msg.clone(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!(msg));
|
||||
}
|
||||
};
|
||||
|
||||
// Copy ourselves to HERMES_HOME/hermes-setup.exe so the desktop app can
|
||||
// re-invoke us with `--update` and shortcuts have a stable target. This is
|
||||
// a one-shot install concern; an `--update` re-invocation no-ops because
|
||||
|
|
@ -660,10 +778,7 @@ async fn run_bootstrap(
|
|||
&app,
|
||||
BootstrapEvent::Complete {
|
||||
install_root: install_root.to_string_lossy().into_owned(),
|
||||
marker: Some(serde_json::json!({
|
||||
"pinnedCommit": pin.commit,
|
||||
"pinnedBranch": pin.branch,
|
||||
})),
|
||||
marker: Some(marker),
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -903,4 +1018,103 @@ mod tests {
|
|||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_complete_marker_uses_desktop_compatible_schema() {
|
||||
let root = unique_tmp_dir("marker-schema");
|
||||
let pin = Pin {
|
||||
commit: Some("abcdef1234567890".to_string()),
|
||||
branch: Some("main".to_string()),
|
||||
};
|
||||
|
||||
let marker =
|
||||
write_bootstrap_complete_marker(&root, &pin).expect("marker write should succeed");
|
||||
let marker_path = root.join(".hermes-bootstrap-complete");
|
||||
let from_disk: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(&marker_path).unwrap()).unwrap();
|
||||
|
||||
assert_eq!(marker, from_disk);
|
||||
assert_eq!(from_disk["schemaVersion"], 1);
|
||||
assert_eq!(from_disk["pinnedCommit"], "abcdef1234567890");
|
||||
assert_eq!(from_disk["pinnedBranch"], "main");
|
||||
assert!(
|
||||
from_disk["completedAtUnix"].as_u64().is_some(),
|
||||
"marker must carry a completion timestamp"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_complete_marker_is_published_atomically() {
|
||||
let root = unique_tmp_dir("marker-atomic");
|
||||
make_release_tree(&root);
|
||||
let pin = Pin {
|
||||
commit: Some("abcdef1234567890".to_string()),
|
||||
branch: Some("main".to_string()),
|
||||
};
|
||||
|
||||
write_bootstrap_complete_marker(&root, &pin).expect("marker write should succeed");
|
||||
|
||||
let marker_path = root.join(".hermes-bootstrap-complete");
|
||||
let tmp_path = root.join(".hermes-bootstrap-complete.tmp");
|
||||
assert!(
|
||||
marker_path.is_file(),
|
||||
"final marker must exist after atomic publish"
|
||||
);
|
||||
assert!(
|
||||
!tmp_path.exists(),
|
||||
"temp sibling must not remain after atomic publish"
|
||||
);
|
||||
assert!(
|
||||
hermes_is_installed(&root),
|
||||
"atomically published marker must enable the installer fast path"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hermes_is_installed_treats_marker_existence_as_sufficient() {
|
||||
// Documents why write_bootstrap_complete_marker must publish atomically:
|
||||
// the launcher predicate only checks existence, so a partial/corrupt
|
||||
// final marker would still enable the fast path.
|
||||
let root = unique_tmp_dir("marker-existence-only");
|
||||
make_release_tree(&root);
|
||||
std::fs::write(root.join(".hermes-bootstrap-complete"), b"").unwrap();
|
||||
|
||||
assert!(
|
||||
hermes_is_installed(&root),
|
||||
"empty/partial marker content still counts as installed"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_write_failure_leaves_no_final_marker() {
|
||||
// install_root is a regular file → create_dir_all on its path fails
|
||||
// before any marker bytes are published under the final name.
|
||||
let base = unique_tmp_dir("marker-fail");
|
||||
let not_a_dir = base.join("not-a-dir");
|
||||
std::fs::write(¬_a_dir, b"not a directory").unwrap();
|
||||
let pin = Pin {
|
||||
commit: Some("abcdef1234567890".to_string()),
|
||||
branch: Some("main".to_string()),
|
||||
};
|
||||
|
||||
let err = write_bootstrap_complete_marker(¬_a_dir, &pin)
|
||||
.expect_err("marker write against a non-directory root must fail");
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("bootstrap marker"),
|
||||
"error should mention the marker path: {msg}"
|
||||
);
|
||||
assert!(
|
||||
!not_a_dir.join(".hermes-bootstrap-complete").exists(),
|
||||
"failed write must not leave a final marker that enables the fast path"
|
||||
);
|
||||
assert!(
|
||||
!not_a_dir.join(".hermes-bootstrap-complete.tmp").exists(),
|
||||
"failed write must not leave a temp marker sibling either"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,8 +149,8 @@ fn repair_macos_installer_helper(path: &Path) {
|
|||
#[cfg(not(target_os = "macos"))]
|
||||
fn repair_macos_installer_helper(_path: &Path) {}
|
||||
|
||||
/// Where install.ps1 writes the bootstrap-complete marker (existence-only file
|
||||
/// the Electron app also checks). Per main.ts:
|
||||
/// Where the bootstrap-complete marker lives (existence-only for the Rust
|
||||
/// installer fast path; JSON schema-checked by the Electron app). Per main.ts:
|
||||
/// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete')
|
||||
/// We don't always know ACTIVE_HERMES_ROOT until install.ps1 reports it, so
|
||||
/// this is a probe helper, not a definitive path.
|
||||
|
|
|
|||
60
apps/desktop/electron/active-runtime-state.test.ts
Normal file
60
apps/desktop/electron/active-runtime-state.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { classifyActiveRuntime, hasValidBootstrapMarker } from './active-runtime-state'
|
||||
|
||||
const VALID_MARKER = {
|
||||
pinnedCommit: '1234567890abcdef1234567890abcdef12345678',
|
||||
schemaVersion: 1
|
||||
}
|
||||
|
||||
test('hasValidBootstrapMarker accepts the current schema with a real-looking commit', () => {
|
||||
assert.equal(hasValidBootstrapMarker(VALID_MARKER, 1), true)
|
||||
})
|
||||
|
||||
test('hasValidBootstrapMarker rejects missing, wrong-schema, and too-short markers', () => {
|
||||
assert.equal(hasValidBootstrapMarker(null, 1), false)
|
||||
assert.equal(hasValidBootstrapMarker({ schemaVersion: 2, pinnedCommit: VALID_MARKER.pinnedCommit }, 1), false)
|
||||
assert.equal(hasValidBootstrapMarker({ schemaVersion: 1, pinnedCommit: 'abc123' }, 1), false)
|
||||
})
|
||||
|
||||
test('classifyActiveRuntime uses a healthy active runtime even when the bootstrap marker is missing', () => {
|
||||
assert.deepEqual(classifyActiveRuntime(null, 1, true), {
|
||||
hasValidMarker: false,
|
||||
shouldUseActiveRuntime: true,
|
||||
usabilityReason: 'usable'
|
||||
})
|
||||
})
|
||||
|
||||
test('classifyActiveRuntime uses a healthy active runtime even when the marker is stale or malformed', () => {
|
||||
assert.deepEqual(classifyActiveRuntime({ schemaVersion: 999, pinnedCommit: 'abc1234' }, 1, true), {
|
||||
hasValidMarker: false,
|
||||
shouldUseActiveRuntime: true,
|
||||
usabilityReason: 'usable'
|
||||
})
|
||||
})
|
||||
|
||||
test('classifyActiveRuntime refuses an unusable runtime even if a valid marker exists', () => {
|
||||
assert.deepEqual(classifyActiveRuntime(VALID_MARKER, 1, false), {
|
||||
hasValidMarker: true,
|
||||
shouldUseActiveRuntime: false,
|
||||
usabilityReason: 'unusable'
|
||||
})
|
||||
})
|
||||
|
||||
test('a CLI-installed runtime with no marker launches instead of re-running bootstrap', () => {
|
||||
// The reported symptom (#60721): install.sh / install.ps1 produced a healthy
|
||||
// repo+venv, no desktop-managed marker was ever written, and every launch
|
||||
// dropped the user back into the first-run installer.
|
||||
const state = classifyActiveRuntime(null, 1, true)
|
||||
|
||||
assert.equal(state.shouldUseActiveRuntime, true, 'a usable runtime must launch')
|
||||
assert.equal(state.hasValidMarker, false, 'marker provenance stays honest')
|
||||
})
|
||||
|
||||
test('a repair that deleted the marker does not strand a healthy install', () => {
|
||||
// #72166: the repair handler clears the marker unconditionally. Runtime
|
||||
// usability, not marker presence, must decide the next boot.
|
||||
assert.equal(classifyActiveRuntime(null, 1, true).shouldUseActiveRuntime, true)
|
||||
})
|
||||
58
apps/desktop/electron/active-runtime-state.ts
Normal file
58
apps/desktop/electron/active-runtime-state.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
export interface BootstrapMarkerLike {
|
||||
pinnedCommit?: unknown
|
||||
schemaVersion?: unknown
|
||||
}
|
||||
|
||||
export interface ActiveRuntimeState {
|
||||
hasValidMarker: boolean
|
||||
shouldUseActiveRuntime: boolean
|
||||
usabilityReason: 'usable' | 'unusable'
|
||||
}
|
||||
|
||||
export function hasValidBootstrapMarker(
|
||||
marker: BootstrapMarkerLike | null | undefined,
|
||||
schemaVersion: number
|
||||
): boolean {
|
||||
if (!marker || typeof marker !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (marker.schemaVersion !== schemaVersion) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// The active install at ~/.hermes/hermes-agent can be real and runnable even if
|
||||
// Desktop never wrote its first-run bootstrap marker (for example when Hermes
|
||||
// was installed by the CLI first, or when a past desktop build forgot the
|
||||
// marker). Runtime usability is authoritative for "can we launch local Hermes
|
||||
// right now?"; the marker is only provenance about how that install was
|
||||
// created. A missing/stale marker must never force a healthy local install into
|
||||
// the first-run bootstrap UI.
|
||||
export function classifyActiveRuntime(
|
||||
marker: BootstrapMarkerLike | null | undefined,
|
||||
schemaVersion: number,
|
||||
runtimeUsable: boolean
|
||||
): ActiveRuntimeState {
|
||||
const hasValidMarker = hasValidBootstrapMarker(marker, schemaVersion)
|
||||
|
||||
if (!runtimeUsable) {
|
||||
return {
|
||||
hasValidMarker,
|
||||
shouldUseActiveRuntime: false,
|
||||
usabilityReason: 'unusable'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasValidMarker,
|
||||
shouldUseActiveRuntime: true,
|
||||
usabilityReason: 'usable'
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ import {
|
|||
} from 'electron'
|
||||
import nodePty from 'node-pty'
|
||||
|
||||
import { classifyActiveRuntime } from './active-runtime-state'
|
||||
import { stopBackendChild as stopBackendChildImpl } from './backend-child'
|
||||
import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command'
|
||||
import { createBackendConnectionState } from './backend-connection-state'
|
||||
|
|
@ -1032,6 +1033,12 @@ let remoteReauthFailure = null
|
|||
// Active first-launch install, so the renderer's Cancel button (and app quit)
|
||||
// can abort the in-flight install.sh/ps1 instead of leaving it running.
|
||||
let bootstrapAbortController = null
|
||||
// Explicit "the user asked for a repair" flag. Repair used to signal intent by
|
||||
// deleting the bootstrap marker, which stranded healthy installs whose only
|
||||
// problem was a transient backend error (#72166). Intent now lives here, so
|
||||
// repair can force the installer without destroying provenance about how the
|
||||
// install was created. Cleared once the reinstall is under way.
|
||||
let bootstrapRepairRequested = false
|
||||
let connectionConfigCache = null
|
||||
let connectionConfigCacheMtime = null
|
||||
const hermesLog = []
|
||||
|
|
@ -3383,11 +3390,11 @@ function readJson(filePath) {
|
|||
}
|
||||
}
|
||||
|
||||
// Bootstrap-complete marker helpers. The marker is written ONCE by the
|
||||
// first-launch bootstrap runner (Phase 1D) after install.ps1 stages succeed
|
||||
// AND the user has finished initial configuration. On every subsequent boot
|
||||
// we check `isBootstrapComplete()` and skip the bootstrap flow entirely if
|
||||
// the marker is present and current-schema.
|
||||
// Bootstrap-complete marker helpers. The marker is written by whichever
|
||||
// installer ran: install.ps1, install.sh, the Rust bootstrap installer, or the
|
||||
// first-launch bootstrap runner. It is provenance ("a bootstrap finished
|
||||
// here"), NOT the launch gate -- activeRuntimeState() decides that, because a
|
||||
// healthy runtime can predate the marker or outlive a repair that cleared it.
|
||||
//
|
||||
// Marker schema (version 1):
|
||||
// {
|
||||
|
|
@ -3420,29 +3427,13 @@ function isActiveRuntimeUsable() {
|
|||
)
|
||||
}
|
||||
|
||||
function isBootstrapComplete() {
|
||||
const marker = readBootstrapMarker()
|
||||
|
||||
if (!marker || typeof marker !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (marker.schemaVersion !== BOOTSTRAP_MARKER_SCHEMA_VERSION) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) {
|
||||
return false
|
||||
}
|
||||
|
||||
function activeRuntimeState() {
|
||||
// We DELIBERATELY do NOT verify that the checkout is currently at the
|
||||
// pinned commit -- users update via the in-app update path or `hermes
|
||||
// update`, which moves HEAD legitimately. The marker just attests "we
|
||||
// ran the bootstrap successfully at least once." We DO additionally require
|
||||
// a runnable venv: an interrupted or split-home install can leave the marker
|
||||
// + checkout without a venv, and trusting that spawns a dead backend
|
||||
// ("gateway offline") instead of re-running bootstrap to repair it.
|
||||
return isActiveRuntimeUsable()
|
||||
// update`, which moves HEAD legitimately. The marker only attests "a
|
||||
// desktop-managed bootstrap ran here at least once"; runtime usability is
|
||||
// what decides whether we can actually launch.
|
||||
return classifyActiveRuntime(readBootstrapMarker(), BOOTSTRAP_MARKER_SCHEMA_VERSION, isActiveRuntimeUsable())
|
||||
}
|
||||
|
||||
function writeBootstrapMarker(payload) {
|
||||
|
|
@ -3702,16 +3693,30 @@ function resolveHermesBackend(backendArgs) {
|
|||
}
|
||||
}
|
||||
|
||||
// 3. Bootstrap-complete ACTIVE_HERMES_ROOT -- the canonical install at
|
||||
// %LOCALAPPDATA%\hermes\hermes-agent (Windows) or ~/.hermes/hermes-agent.
|
||||
// The bootstrap marker means install.ps1 stages finished and the user
|
||||
// completed initial configuration; we trust the install and go straight
|
||||
// to spawning hermes. Updates flow through the in-app update path
|
||||
// (applyUpdates -> git pull) or `hermes update` from the CLI.
|
||||
if (isBootstrapComplete()) {
|
||||
// 3. ACTIVE_HERMES_ROOT — the canonical install at
|
||||
// %LOCALAPPDATA%\\hermes\\hermes-agent (Windows) or ~/.hermes/hermes-agent.
|
||||
// A valid bootstrap marker proves Desktop finished the first-run install
|
||||
// flow, but marker provenance is NOT the same thing as runtime usability:
|
||||
// the CLI can create the exact same repo+venv layout, and older desktop
|
||||
// builds could leave a healthy install behind without the marker. If the
|
||||
// active runtime is usable, launch it directly; only fall through to
|
||||
// bootstrap when the runtime itself is unusable.
|
||||
const activeRuntime = activeRuntimeState()
|
||||
|
||||
if (activeRuntime.shouldUseActiveRuntime && !bootstrapRepairRequested) {
|
||||
if (!activeRuntime.hasValidMarker) {
|
||||
rememberLog(
|
||||
`[bootstrap] Active Hermes runtime at ${ACTIVE_HERMES_ROOT} is usable but the bootstrap marker is missing or stale; skipping first-run bootstrap.`
|
||||
)
|
||||
}
|
||||
|
||||
return createActiveBackend(backendArgs)
|
||||
}
|
||||
|
||||
if (bootstrapRepairRequested) {
|
||||
rememberLog('[bootstrap] repair requested; bypassing the usable active runtime to re-run the installer')
|
||||
}
|
||||
|
||||
// 4. Existing `hermes` on PATH -- installed via install.ps1 / install.sh from
|
||||
// a previous tool-only setup, or pip-installed system-wide. Use it but
|
||||
// do NOT write a bootstrap marker; the user did this themselves and we
|
||||
|
|
@ -3885,6 +3890,10 @@ async function ensureRuntime(backend) {
|
|||
|
||||
bootstrapAbortController = new AbortController()
|
||||
|
||||
// The repair request has been honoured by reaching the installer; clear it
|
||||
// so a later boot isn't forced through bootstrap again.
|
||||
bootstrapRepairRequested = false
|
||||
|
||||
const bootstrapResult = await runBootstrap({
|
||||
installStamp: backend.installStamp,
|
||||
activeRoot: backend.activeRoot,
|
||||
|
|
@ -3979,10 +3988,10 @@ async function ensureRuntime(backend) {
|
|||
// No venv at the expected location AND no bootstrap-needed sentinel
|
||||
// means we have a half-installed checkout: .git exists, source files
|
||||
// exist, but venv is missing or broken. This shouldn't happen in
|
||||
// normal flow because isBootstrapComplete() requires
|
||||
// isHermesSourceRoot() and the bootstrap writes the marker only after
|
||||
// install.ps1 succeeds. If we hit this, the user (or a deleted venv)
|
||||
// broke the invariant; tell them to re-run the install.
|
||||
// normal flow because activeRuntimeState() requires isHermesSourceRoot()
|
||||
// plus an importable hermes_cli before it hands back the active runtime.
|
||||
// If we hit this, the user (or a deleted venv) broke the invariant; tell
|
||||
// them to re-run the install.
|
||||
throw new Error(
|
||||
`Hermes venv missing at ${VENV_ROOT}. Re-run the desktop installer or ` + '`scripts/install.ps1` to rebuild it.'
|
||||
)
|
||||
|
|
@ -9106,20 +9115,17 @@ ipcMain.handle('hermes:bootstrap:reset', async () => {
|
|||
return { ok: true }
|
||||
})
|
||||
ipcMain.handle('hermes:bootstrap:repair', async () => {
|
||||
// Forceful repair: drop the bootstrap-complete marker so the next
|
||||
// startHermes() re-runs the full installer (refreshing a broken/partial
|
||||
// venv), and clear any latched failure + live connection. The renderer
|
||||
// reloads afterwards to re-drive the boot flow from scratch.
|
||||
rememberLog('[bootstrap] repair requested by renderer; clearing marker + latched failure')
|
||||
|
||||
try {
|
||||
if (fileExists(BOOTSTRAP_COMPLETE_MARKER)) {
|
||||
fs.rmSync(BOOTSTRAP_COMPLETE_MARKER, { force: true })
|
||||
}
|
||||
} catch (error) {
|
||||
rememberLog(`[bootstrap] failed to remove marker during repair: ${error.message}`)
|
||||
}
|
||||
// Forceful repair: force the next startHermes() through the full installer
|
||||
// (refreshing a broken/partial venv) and clear any latched failure + live
|
||||
// connection. The renderer reloads afterwards to re-drive the boot flow.
|
||||
//
|
||||
// We do NOT delete the bootstrap marker here. Repair is also reachable from
|
||||
// transient backend errors on a perfectly healthy install, and deleting the
|
||||
// marker in that case stranded the app in first-run setup with no way back
|
||||
// (#72166). The explicit flag carries the intent instead.
|
||||
rememberLog('[bootstrap] repair requested by renderer; forcing reinstall + clearing latched failure')
|
||||
|
||||
bootstrapRepairRequested = true
|
||||
bootstrapFailure = null
|
||||
backendStartFailure = null
|
||||
remoteReauthFailure = null
|
||||
|
|
|
|||
|
|
@ -2391,6 +2391,48 @@ maybe_start_gateway() {
|
|||
fi
|
||||
}
|
||||
|
||||
write_bootstrap_marker() {
|
||||
# Writes $INSTALL_DIR/.hermes-bootstrap-complete, which tells the Hermes
|
||||
# desktop app (apps/desktop/electron/main.ts) and the macOS launcher fast
|
||||
# path (apps/bootstrap-installer) "a real install finished here -- don't
|
||||
# re-run first-run bootstrap."
|
||||
#
|
||||
# Schema mirrors install.ps1's Write-BootstrapMarker and main.ts's
|
||||
# writeBootstrapMarker(). Keep the three in lockstep:
|
||||
# schemaVersion 1 + pinnedCommit (length >= 7) are what the desktop
|
||||
# validator requires; desktopVersion is omitted because only the desktop
|
||||
# app knows its own version.
|
||||
if [ ! -d "$INSTALL_DIR" ]; then
|
||||
log_warn "Skipping bootstrap marker: $INSTALL_DIR doesn't exist"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Explicit --commit wins; otherwise read HEAD from the checkout we just
|
||||
# installed. If neither resolves, skip the marker entirely rather than
|
||||
# write one the desktop will reject -- an absent marker is a clean
|
||||
# "bootstrap needed", a malformed one is a confusing half-state.
|
||||
local pinned_commit="$INSTALL_COMMIT"
|
||||
if [ -z "$pinned_commit" ]; then
|
||||
pinned_commit=$(git -C "$INSTALL_DIR" rev-parse HEAD 2>/dev/null) || pinned_commit=""
|
||||
fi
|
||||
|
||||
if [ -z "$pinned_commit" ]; then
|
||||
log_warn "Skipping bootstrap marker: could not resolve HEAD in $INSTALL_DIR"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local marker_path="$INSTALL_DIR/.hermes-bootstrap-complete"
|
||||
local tmp_path="$marker_path.tmp"
|
||||
|
||||
# Atomic publish: the macOS launcher predicate only checks existence, so a
|
||||
# torn write would arm the fast path against a half-written marker.
|
||||
printf '{\n "schemaVersion": 1,\n "pinnedCommit": "%s",\n "pinnedBranch": "%s",\n "completedAt": "%s"\n}\n' \
|
||||
"$pinned_commit" \
|
||||
"$BRANCH" \
|
||||
"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" > "$tmp_path"
|
||||
mv -f "$tmp_path" "$marker_path"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo ""
|
||||
echo -e "${GREEN}${BOLD}"
|
||||
|
|
@ -3024,6 +3066,7 @@ run_stage_body() {
|
|||
detect_os
|
||||
resolve_install_layout
|
||||
print_success
|
||||
write_bootstrap_marker
|
||||
# Code-scoped stamp: write next to the install tree, not into
|
||||
# $HERMES_HOME. $HERMES_HOME is a shared data dir (it can be
|
||||
# bind-mounted into a Docker gateway too), so a stamp there gets
|
||||
|
|
@ -3108,6 +3151,8 @@ main() {
|
|||
|
||||
print_success
|
||||
|
||||
write_bootstrap_marker
|
||||
|
||||
# Code-scoped stamp: write next to the install tree, not into $HERMES_HOME.
|
||||
# $HERMES_HOME is a shared data dir (it can be bind-mounted into a Docker
|
||||
# gateway too), so a stamp there gets clobbered by the container's 'docker'
|
||||
|
|
|
|||
109
tests/test_install_sh_bootstrap_marker.py
Normal file
109
tests/test_install_sh_bootstrap_marker.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"""install.sh must stamp the desktop bootstrap-complete marker.
|
||||
|
||||
The marker at ``$INSTALL_DIR/.hermes-bootstrap-complete`` is what the desktop
|
||||
app (apps/desktop/electron/main.ts) and the macOS launcher fast path
|
||||
(apps/bootstrap-installer) use to decide "a real install finished here."
|
||||
install.sh never wrote it, so a CLI-installed Mac/Linux box re-ran first-run
|
||||
bootstrap on every desktop launch (#60721).
|
||||
|
||||
These exercise the real shell function against a temp checkout rather than
|
||||
asserting on the text of install.sh.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
INSTALL_SH = REPO_ROOT / "scripts" / "install.sh"
|
||||
|
||||
|
||||
def run_write_marker(install_dir, *, commit="", branch="main"):
|
||||
"""Source install.sh and invoke write_bootstrap_marker in isolation.
|
||||
|
||||
install.sh guards its own entrypoint behind MANIFEST_MODE/STAGE_NAME/main,
|
||||
so sourcing it with --help-less argv defines the functions without running
|
||||
an install.
|
||||
"""
|
||||
script = f"""
|
||||
set -e
|
||||
INSTALL_DIR={install_dir!s}
|
||||
INSTALL_COMMIT={commit!r}
|
||||
BRANCH={branch!r}
|
||||
# Pull in the function definitions without triggering an install.
|
||||
eval "$(sed -n '/^write_bootstrap_marker()/,/^}}/p' {INSTALL_SH!s})"
|
||||
log_warn() {{ echo "WARN: $*" >&2; }}
|
||||
write_bootstrap_marker
|
||||
"""
|
||||
return subprocess.run(
|
||||
["bash", "-c", script], capture_output=True, text=True, timeout=30
|
||||
)
|
||||
|
||||
|
||||
def make_checkout(tmp_path):
|
||||
install_dir = tmp_path / "hermes-agent"
|
||||
install_dir.mkdir()
|
||||
subprocess.run(["git", "init", "-q"], cwd=install_dir, check=True)
|
||||
subprocess.run(
|
||||
["git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q",
|
||||
"--allow-empty", "-m", "init"],
|
||||
cwd=install_dir,
|
||||
check=True,
|
||||
)
|
||||
return install_dir
|
||||
|
||||
|
||||
def test_marker_matches_the_schema_the_desktop_validates(tmp_path):
|
||||
"""Desktop's isBootstrapComplete() needs schemaVersion 1 + a >=7 char commit."""
|
||||
install_dir = make_checkout(tmp_path)
|
||||
|
||||
result = run_write_marker(install_dir)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
marker = install_dir / ".hermes-bootstrap-complete"
|
||||
assert marker.is_file(), "install.sh must stamp the bootstrap marker"
|
||||
|
||||
payload = json.loads(marker.read_text())
|
||||
assert payload["schemaVersion"] == 1
|
||||
assert len(payload["pinnedCommit"]) >= 7
|
||||
assert payload["pinnedBranch"] == "main"
|
||||
assert payload["completedAt"].endswith("Z")
|
||||
|
||||
|
||||
def test_marker_publish_leaves_no_temp_sibling(tmp_path):
|
||||
"""The launcher predicate is existence-only, so the write must be atomic."""
|
||||
install_dir = make_checkout(tmp_path)
|
||||
|
||||
run_write_marker(install_dir)
|
||||
|
||||
assert (install_dir / ".hermes-bootstrap-complete").is_file()
|
||||
assert not (install_dir / ".hermes-bootstrap-complete.tmp").exists()
|
||||
|
||||
|
||||
def test_explicit_commit_pin_wins_over_head(tmp_path):
|
||||
install_dir = make_checkout(tmp_path)
|
||||
pinned = "abcdef1234567890abcdef1234567890abcdef12"
|
||||
|
||||
run_write_marker(install_dir, commit=pinned)
|
||||
|
||||
payload = json.loads((install_dir / ".hermes-bootstrap-complete").read_text())
|
||||
assert payload["pinnedCommit"] == pinned
|
||||
|
||||
|
||||
def test_no_marker_written_when_head_cannot_be_resolved(tmp_path):
|
||||
"""A malformed marker is worse than none: absent means a clean re-bootstrap."""
|
||||
install_dir = tmp_path / "not-a-checkout"
|
||||
install_dir.mkdir()
|
||||
|
||||
result = run_write_marker(install_dir)
|
||||
|
||||
assert result.returncode == 0, "an unresolvable HEAD must not fail the install"
|
||||
assert not (install_dir / ".hermes-bootstrap-complete").exists()
|
||||
|
||||
|
||||
def test_missing_install_dir_is_not_fatal(tmp_path):
|
||||
result = run_write_marker(tmp_path / "does-not-exist")
|
||||
|
||||
assert result.returncode == 0
|
||||
Loading…
Add table
Add a link
Reference in a new issue