Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions app/src/components/InitProgressScreen/HarnessInitOverlay.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import type { HarnessInitSnapshot } from '../../services/harnessInitService';
import { renderWithProviders } from '../../test/test-utils';
// Imported after the mock is registered.
import HarnessInitOverlay from './HarnessInitOverlay';

// The overlay polls the service; drive it with a controllable mock.
const fetchHarnessInitStatus = vi.fn<() => Promise<HarnessInitSnapshot | null>>();
const runHarnessInit = vi.fn<(force?: boolean) => Promise<HarnessInitSnapshot | null>>();

vi.mock('../../services/harnessInitService', () => ({
fetchHarnessInitStatus: () => fetchHarnessInitStatus(),
runHarnessInit: (force?: boolean) => runHarnessInit(force),
}));

function snapshot(overrides: Partial<HarnessInitSnapshot> = {}): HarnessInitSnapshot {
return {
overall: 'running',
startedAt: '2026-07-20T00:00:00Z',
finishedAt: null,
steps: [
{
id: 'python_runtime',
label: 'Python runtime',
required: false,
state: 'running',
message: null,
percent: null,
updatedAt: null,
},
],
...overrides,
};
}

beforeEach(() => {
fetchHarnessInitStatus.mockReset();
runHarnessInit.mockReset();
window.sessionStorage.clear();
});

afterEach(() => {
vi.restoreAllMocks();
});

describe('HarnessInitOverlay', () => {
it('renders nothing on a warm start (already done)', async () => {
fetchHarnessInitStatus.mockResolvedValue(snapshot({ overall: 'done', startedAt: 'warm-run' }));

const { container } = renderWithProviders(<HarnessInitOverlay />);

await waitFor(() => expect(fetchHarnessInitStatus).toHaveBeenCalled());
expect(screen.queryByText('Run in background')).not.toBeInTheDocument();
expect(container).toBeEmptyDOMElement();
});

it('shows the blocking overlay while a provisioning run is in progress', async () => {
fetchHarnessInitStatus.mockResolvedValue(snapshot({ startedAt: 'cold-run' }));

renderWithProviders(<HarnessInitOverlay />);

expect(await screen.findByText('Run in background')).toBeInTheDocument();
});

it('keeps the overlay dismissed across a remount for the same run (GH-5047)', async () => {
const run = snapshot({ startedAt: 'same-run' });
fetchHarnessInitStatus.mockResolvedValue(run);

const first = renderWithProviders(<HarnessInitOverlay />);
fireEvent.click(await screen.findByText('Run in background'));
await waitFor(() => expect(screen.queryByText('Run in background')).not.toBeInTheDocument());
first.unmount();

// Remount while the same run is still in progress — it must not reopen.
const second = renderWithProviders(<HarnessInitOverlay />);
await waitFor(() => expect(fetchHarnessInitStatus).toHaveBeenCalled());
// Give any pending poll a chance to (wrongly) re-render the overlay.
await Promise.resolve();
expect(screen.queryByText('Run in background')).not.toBeInTheDocument();
expect(second.container).toBeEmptyDOMElement();
});

it('reopens for a genuinely new provisioning run after a prior dismissal', async () => {
// Dismiss the first run.
fetchHarnessInitStatus.mockResolvedValue(snapshot({ startedAt: 'run-1' }));
const first = renderWithProviders(<HarnessInitOverlay />);
fireEvent.click(await screen.findByText('Run in background'));
await waitFor(() => expect(screen.queryByText('Run in background')).not.toBeInTheDocument());
first.unmount();

// A new run (fresh startedAt) is allowed to surface again.
fetchHarnessInitStatus.mockResolvedValue(snapshot({ startedAt: 'run-2' }));
renderWithProviders(<HarnessInitOverlay />);
expect(await screen.findByText('Run in background')).toBeInTheDocument();
});
});
68 changes: 66 additions & 2 deletions app/src/components/InitProgressScreen/HarnessInitOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,50 @@ const log = debugFactory('harness-init');

const POLL_MS = 2000;

// Persist the "Run in background" dismissal for the *current* provisioning run
// so a remount or reload does not reopen the overlay (GH-5047). A run is keyed
// by its `startedAt` timestamp — a genuinely new provisioning run gets a fresh
// timestamp and is allowed to surface again. `sessionStorage` survives a
// renderer reload within the same window; a module-level mirror covers plain
// React remounts even if storage is unavailable.
const DISMISS_KEY = 'harness-init-dismissed-run';
// Runs before `startedAt` is stamped (or when it is absent) still need a stable
// key so an early dismissal sticks.
const UNKEYED_RUN = 'pending';

let dismissedRunMirror: string | null = null;

function runKey(snapshot: HarnessInitSnapshot | null): string {
return snapshot?.startedAt ?? UNKEYED_RUN;
}

function readDismissedRun(): string | null {
if (dismissedRunMirror !== null) {
return dismissedRunMirror;
}
try {
return window.sessionStorage.getItem(DISMISS_KEY);
} catch {
log('sessionStorage read failed; treating run as not dismissed');
return null;
}
}

function writeDismissedRun(key: string): void {
dismissedRunMirror = key;
try {
window.sessionStorage.setItem(DISMISS_KEY, key);
log('dismissed run persisted to sessionStorage: %s', key);
} catch {
// Non-fatal: the module-level mirror still guards remounts this session.
log('sessionStorage unavailable; dismissed run %s held in module mirror only', key);
}
}

function isRunDismissed(snapshot: HarnessInitSnapshot | null): boolean {
return readDismissedRun() === runKey(snapshot);
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
/**
* Blocking first-run initialization gate.
*
Expand Down Expand Up @@ -47,6 +91,18 @@ export default function HarnessInitOverlay() {
}
if (next) {
setSnapshot(next);
// If this run was already dismissed to the background (possibly in a
// prior mount / before a reload), stay hidden and stop polling —
// don't let a remount reopen the overlay (GH-5047).
if (isRunDismissed(next)) {
log(
'warm poll: run %s already dismissed — staying hidden, stopping poll',
runKey(next)
);
dismissedRef.current = true;
setDismissed(true);
return;
}
// Stop polling once the run is terminal; a `failed` snapshot stays
// on screen (with Retry) but does not need further polling.
if (next.overall === 'done' || next.overall === 'failed') {
Expand Down Expand Up @@ -88,15 +144,23 @@ export default function HarnessInitOverlay() {

const handleContinue = useCallback(() => {
// Hide the overlay and stop polling; the core keeps running init as a
// background task regardless.
// background task regardless. Persist the dismissal for this run so a
// remount/reload does not reopen it (GH-5047).
log('user dismissed overlay to background for run %s', runKey(snapshot));
writeDismissedRun(runKey(snapshot));
dismissedRef.current = true;
setDismissed(true);
}, []);
}, [snapshot]);

if (dismissed || !snapshot) {
return null;
}

// A run dismissed to the background stays hidden across remounts.
if (isRunDismissed(snapshot)) {
return null;
}

// Block only while a run is actively in progress, or hold a failed run on
// screen until the user explicitly continues. `idle` (no run started yet)
// and `done` never block.
Expand Down
46 changes: 43 additions & 3 deletions src/openhuman/harness_init/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,30 @@ use crate::openhuman::config::Config;
/// *required* step fails.
pub async fn run_harness_init_with(config: Config, force: bool) -> HarnessInitSnapshot {
log::info!("[harness_init] starting one-time init run (force={force})");
store::set_overall(OverallState::Running);

let steps = registry::all_steps();

// Warm-start guard (GH-5047): decide visibility *before* publishing any
// `Running` state. The blocking overlay is justified only when a
// provisioning step (download/install/repair) genuinely needs work. Routine
// startup — e.g. re-launching an already-installed local Python server — is
// NOT provisioning and must run silently in the background on every launch.
// Probing on-disk readiness first (durably) keeps the overlay off warm
// restarts entirely instead of flashing it while the run settles.
let needs_provisioning = force || provisioning_required(&config, &steps).await;
if needs_provisioning {
log::info!("[harness_init] provisioning required — surfacing setup overlay");
store::set_overall(OverallState::Running);
} else {
log::info!(
"[harness_init] warm start — all provisioning satisfied; running remaining startup silently"
);
}

let mut failed_required = false;

for step in registry::all_steps() {
run_one(&config, &step, force, &mut failed_required).await;
for step in &steps {
run_one(&config, step, force, &mut failed_required).await;
}

let overall = if failed_required {
Expand All @@ -43,6 +61,28 @@ pub async fn run_harness_init(config: Config) {
run_harness_init_with(config, false).await;
}

/// Whether any *provisioning* step (download/install/repair) still needs work.
///
/// Only provisioning steps gate the visible overlay; a non-provisioning step
/// (routine service startup) is ignored here so an already-installed host never
/// surfaces the blocking screen just to relaunch a background server. Each
/// probe is the step's durable, network-free `is_done` — see [`registry`].
async fn provisioning_required(config: &Config, steps: &[HarnessInitStep]) -> bool {
for step in steps {
if !step.provisioning {
continue;
}
if !(step.is_done)(config).await {
log::info!(
"[harness_init] provisioning step {} not satisfied on disk — setup needed",
step.id
);
return true;
}
}
false
}

async fn run_one(config: &Config, step: &HarnessInitStep, force: bool, failed_required: &mut bool) {
if !force && (step.is_done)(config).await {
log::debug!("[harness_init] step {} already satisfied", step.id);
Expand Down
51 changes: 47 additions & 4 deletions src/openhuman/harness_init/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,20 @@ pub struct HarnessInitStep {
pub label: &'static str,
/// When true, a failure blocks the app. All steps are non-required today.
pub required: bool,
/// When true this step may **download/install/repair** — the only kind of
/// work that justifies the blocking first-run overlay. When false the step
/// is routine startup (e.g. launching an already-provisioned local server)
/// that must run silently in the background on every launch and must NOT
/// surface the overlay. Drives the warm-start visibility decision in
/// [`super::ops`].
pub provisioning: bool,
/// Cheap, network-free probe: is this step already satisfied on this host?
/// When true the orchestrator marks it `Done` without invoking `run`.
///
/// **Must be durable across process restarts** for provisioning steps — a
/// process-local memo (e.g. `try_cached`) reads empty on every launch and
/// would wrongly re-enter a visible `running` state (GH-5047). Probe the
/// on-disk install instead.
pub is_done: for<'a> fn(&'a Config) -> StepFuture<'a, bool>,
/// Perform the work. `Ok(())` → done; `Err(msg)` → failed/skipped.
pub run: for<'a> fn(&'a Config) -> StepFuture<'a, Result<(), String>>,
Expand All @@ -55,6 +67,7 @@ fn python_runtime_step() -> HarnessInitStep {
id: "python_runtime",
label: "Python runtime",
required: false,
provisioning: true,
is_done: |config| Box::pin(python_is_done(config)),
run: |config| Box::pin(python_run(config)),
}
Expand All @@ -65,11 +78,13 @@ async fn python_is_done(config: &Config) -> bool {
// Disabled → nothing to provision; treat as satisfied.
return true;
}
// Memoised within this process. Across restarts this is None at boot and
// the (fast) `resolve()` probe in `run` settles it quickly.
// Durable on-disk probe: survives restarts (unlike the process-local
// `try_cached`), so an already-installed interpreter is detected without
// entering a user-visible provisioning run (GH-5047). Never downloads.
use crate::openhuman::runtime_python::PythonBootstrap;
PythonBootstrap::new(config.runtime_python.clone())
.try_cached()
.probe_installed()
.await
.is_some()
}

Expand Down Expand Up @@ -98,6 +113,10 @@ fn runtime_python_server_step() -> HarnessInitStep {
id: "runtime_python_server",
label: "Runtime Python server",
required: false,
// Routine service startup, not an install: launching an already
// provisioned server must stay silent (its venv is provisioned by the
// `spacy` / `kompress` steps, which are the ones that surface progress).
provisioning: false,
Comment thread
M3gA-Mind marked this conversation as resolved.
is_done: |config| Box::pin(runtime_python_server_is_done(config)),
run: |config| Box::pin(runtime_python_server_run(config)),
}
Expand Down Expand Up @@ -128,6 +147,7 @@ fn spacy_step() -> HarnessInitStep {
id: "spacy",
label: "spaCy language model",
required: false,
provisioning: true,
is_done: |config| Box::pin(spacy_is_done(config)),
run: |config| Box::pin(spacy_run(config)),
}
Expand Down Expand Up @@ -159,6 +179,7 @@ fn kompress_step() -> HarnessInitStep {
id: "kompress",
label: "TokenJuice ML compressor (torch)",
required: false,
provisioning: true,
is_done: |config| Box::pin(kompress_is_done(config)),
run: |config| Box::pin(kompress_run(config)),
}
Expand Down Expand Up @@ -199,6 +220,7 @@ fn node_runtime_step() -> HarnessInitStep {
id: "node_runtime",
label: "Node.js runtime",
required: false,
provisioning: true,
is_done: |config| Box::pin(node_is_done(config)),
run: |config| Box::pin(node_run(config)),
}
Expand All @@ -216,7 +238,13 @@ async fn node_is_done(config: &Config) -> bool {
if !config.node.enabled {
return true;
}
build_node_bootstrap(config).try_cached().is_some()
// Durable on-disk probe: survives restarts (unlike the process-local
// `try_cached`), so an already-installed toolchain is detected without
// entering a user-visible provisioning run (GH-5047). Never downloads.
build_node_bootstrap(config)
.probe_installed()
.await
.is_some()
}

async fn node_run(config: &Config) -> Result<(), String> {
Expand Down Expand Up @@ -258,6 +286,21 @@ mod tests {
assert!(steps.iter().all(|s| !s.label.is_empty()));
}

/// GH-5047: only genuine install/download steps may surface the blocking
/// overlay. `runtime_python_server` is routine service startup and must be
/// classified non-provisioning so a warm restart never re-shows setup.
#[test]
fn provisioning_classification_excludes_service_startup() {
for step in all_steps() {
let expected = step.id != "runtime_python_server";
assert_eq!(
step.provisioning, expected,
"step {} provisioning flag mismatch",
step.id
);
}
}

#[tokio::test]
async fn disabled_runtimes_report_done_without_work() {
let mut config = Config::default();
Expand Down
Loading
Loading